diff --git a/.gitignore b/.gitignore index 84341d6d..cc79b634 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ ### Mkdocs Site ### site/ .venv-*/ +/docs/plan-*-*.md # Ignore codespaces / C# Dev Kit files .mono @@ -508,4 +509,5 @@ FodyWeavers.xsd # Additional files built by Visual Studio workload-install.ps1 -# End of https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,rider \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,rider +.idea/ diff --git a/docs/STACK.md b/docs/STACK.md new file mode 100644 index 00000000..af567365 --- /dev/null +++ b/docs/STACK.md @@ -0,0 +1,16 @@ +# Project Stack + +- Language: C# with nullable reference types enabled. +- Runtime/SDK target: .NET 10.0. +- Web framework: ASP.NET Core with OData controllers. +- Data providers: Entity Framework Core, Cosmos DB SDK, MongoDB, LiteDB, and in-memory repositories. +- API documentation: OpenAPI, Swashbuckle, and NSwag integration projects. +- Serialization: System.Text.Json with Datasync-specific converters. +- Tests: xUnit v3 with AwesomeAssertions and NSubstitute. +- Build shape: `Datasync.Toolkit.sln`, central package versions in `Directory.Packages.props`, shared project settings in `src/Directory.Build.props` and `tests/Directory.Build.props`. + +## Considerations + +- Samples require platform-specific SDKs and should be validated through the documented GitHub Actions sample workflow instead of local workload installation. +- Unit tests live under `tests/CommunityToolkit.Datasync.*.Test`. +- Generated outputs under `bin/` and `obj/` are ignored and should not be edited. diff --git a/docs/in-depth/server/db/cosmos-sdk.md b/docs/in-depth/server/db/cosmos-sdk.md index d563adbf..82ce321f 100644 --- a/docs/in-depth/server/db/cosmos-sdk.md +++ b/docs/in-depth/server/db/cosmos-sdk.md @@ -92,6 +92,14 @@ Azure Cosmos DB is a fully managed NoSQL database for high-performance applicati builder.Services.AddSingleton>(new CosmosSharedTableOptions("TodoDb", "TodoContainer")); builder.Services.AddSingleton>(new CosmosSharedTableOptions("TodoDb", "TodoContainer")); + If your entity uses custom CLR property names for Datasync metadata, pass the same `TableDataPropertyMap` that you configured for Datasync services: + + builder.Services.AddSingleton>(services => + { + IDatasyncServiceOptions options = services.GetRequiredService(); + return new CosmosSharedTableOptions("TodoDb", "TodoContainer", tableDataProperties: options.TableDataProperties); + }); + 5. Add the Cosmos repositories to the services collection within `Program.cs` with the following code: builder.Services.AddSingleton(typeof(IRepository<>), typeof(CosmosTableRepository<>)); @@ -125,4 +133,4 @@ Azure Cosmos DB is supported in the `Microsoft.AspNetCore.Datasync.CosmosDb` NuG * [Azure Cosmos DB .NET SDK](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/quickstart-dotnet) documentation. * [Cosmos DB index policy](https://learn.microsoft.com/azure/cosmos-db/index-policy) documentation. -* [Cosmos DB single container reference sample](https://github.com/CommunityToolkit/Datasync/tree/main/samples/datasync-server-cosmosdb-singlecontainer) \ No newline at end of file +* [Cosmos DB single container reference sample](https://github.com/CommunityToolkit/Datasync/tree/main/samples/datasync-server-cosmosdb-singlecontainer) diff --git a/docs/in-depth/server/db/in-memory.md b/docs/in-depth/server/db/in-memory.md index 4ff50511..c0608739 100644 --- a/docs/in-depth/server/db/in-memory.md +++ b/docs/in-depth/server/db/in-memory.md @@ -9,6 +9,14 @@ You can create an in-memory repository with no persistent storage by adding a si IEnumerable seedData = GenerateSeedData(); builder.Services.AddSingleton>(new InMemoryRepository(seedData)); +If your entity uses custom CLR property names for Datasync metadata, pass the same `TableDataPropertyMap` that you configured for Datasync services: + + builder.Services.AddSingleton>(services => + { + IDatasyncServiceOptions options = services.GetRequiredService(); + return new InMemoryRepository(seedData, options.TableDataProperties); + }); + Set up your table controller as follows: [Route("tables/[controller]")] diff --git a/docs/in-depth/server/db/litedb.md b/docs/in-depth/server/db/litedb.md index a95b4c50..15a323ae 100644 --- a/docs/in-depth/server/db/litedb.md +++ b/docs/in-depth/server/db/litedb.md @@ -34,6 +34,13 @@ To use LiteDb with on-disk persistent storage: } } + If your entity uses custom CLR property names for Datasync metadata, pass the same `TableDataPropertyMap` that you configured for Datasync services: + + public TodoItemController(LiteDatabase db, IDatasyncServiceOptions options) : base() + { + Repository = new LiteDbRepository(db, "todoitems", options.TableDataProperties); + } + ## Support and further information For more information, review the [LiteDb documentation](https://www.litedb.org/docs/). diff --git a/docs/in-depth/server/db/mongodb.md b/docs/in-depth/server/db/mongodb.md index 89864b39..2719a4cd 100644 --- a/docs/in-depth/server/db/mongodb.md +++ b/docs/in-depth/server/db/mongodb.md @@ -21,7 +21,7 @@ Your entity should inherit from `MongoTableData`: In your table controller: [Route("tables/[controller]")] - public class MyEntityController : TableController + public class MyEntityController : TableController { public MyEntityController(MongoClient client) { @@ -30,6 +30,18 @@ In your table controller: } } +If your entity uses custom CLR property names for Datasync metadata, pass the same `TableDataPropertyMap` that you configured for Datasync services: + + [Route("tables/[controller]")] + public class MyEntityController : TableController + { + public MyEntityController(MongoClient client, IDatasyncServiceOptions options) + { + IMongoDatabase database = client.GetDatabase("mydatabase"); + Repository = new MongoDBRepository(database.GetCollection("entities"), options.TableDataProperties); + } + } + ## Known issues The MongoDB implementation within Cosmos is split into vCore and RU format (also known as dedicated and serverless SKUs). Do not use the "serverless" or RU SKU. Ensure you are using Azure Cosmos DB for MongoDB (vCore). For more information, see [the Azure documentation](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/overview). diff --git a/docs/in-depth/server/index.md b/docs/in-depth/server/index.md index ddb5891e..3acee199 100644 --- a/docs/in-depth/server/index.md +++ b/docs/in-depth/server/index.md @@ -85,6 +85,54 @@ The `ITableData` interfaces provides: The Datasync libraries or the database maintains these properties. Do not modify these properties in your own code. +## Configure metadata property names + +By default, Datasync uses the `ITableData` property names (`Id`, `UpdatedAt`, `Version`, and `Deleted`) when it reads or writes system metadata. If your server entity exposes that metadata through different CLR property names, configure a `TableDataPropertyMap`. + +The mapped properties must be public read/write properties with the following types: + +* `id`: `string` +* `updatedAt`: `DateTimeOffset` or `DateTimeOffset?` +* `version`: `byte[]` +* `deleted`: `bool` + +The map uses CLR property names. With the default JSON options, those names are converted to camelCase in OpenAPI schema output. + + builder.Services.AddDatasyncServices(options => + { + options.TableDataProperties.Map( + id: "Key", + updatedAt: "ChangedOn", + version: "Token", + deleted: "Removed"); + }); + +Repositories that are created manually must use the same map. The simplest option is to resolve `IDatasyncServiceOptions` when creating the repository: + + builder.Services.AddScoped>(services => + { + AppDbContext context = services.GetRequiredService(); + IDatasyncServiceOptions options = services.GetRequiredService(); + return new EntityTableRepository(context, options.TableDataProperties); + }); + +You can override the metadata map for a single controller with `TableControllerOptions.TableDataProperties`: + + [Route("tables/[controller]")] + public class TodoItemController : TableController + { + public TodoItemController(AppDbContext context) : base() + { + TableDataPropertyMap tableDataProperties = new TableDataPropertyMap() + .Map(id: "Key", updatedAt: "ChangedOn", version: "Token", deleted: "Removed"); + + Repository = new EntityTableRepository(context, tableDataProperties); + Options = new TableControllerOptions { TableDataProperties = tableDataProperties }; + } + } + +Use one metadata map consistently for the controller, repository, and OpenAPI generator. If they use different names, conditional requests, soft-delete, ordering, and generated schemas can refer to different properties. + ## Update the DbContext Each model in the database must be registered in the `DbContext`. For example: @@ -131,6 +179,7 @@ The options you can set include: * `MaxTop` (int, default: 512000) is the maximum number of items a user can request in a single operation. * `EnableSoftDelete` (bool, default: false) enables soft-delete, which marks items as deleted instead of deleting them from the database. Soft delete allows clients to update their offline cache, but requires that deleted items are purged from the database separately. * `UnauthorizedStatusCode` (int, default: 401 Unauthorized) is the status code returned when the user isn't allowed to do an action. The value must be a client error (4xx) status code in the range 400-499. +* `TableDataProperties` (`TableDataPropertyMap`, default: global Datasync service options) controls which CLR properties are used for Datasync metadata on this controller. * `UnsafeEntityLogging` (bool, default: false) controls how much entity data is written to the logs. When `false`, only the entity ID is logged at `Information` level. When `true`, the entity ID is logged at `Information` level and the full (serialized) entity contents are logged at `Debug` level. Entity contents may include personally identifiable information (PII), secrets, or other sensitive business data, so only enable this option when the additional diagnostic detail is required and the log sink is appropriately secured. ## Configure access permissions diff --git a/docs/in-depth/server/openapi/net10.md b/docs/in-depth/server/openapi/net10.md index 3f46a924..252a2ae6 100644 --- a/docs/in-depth/server/openapi/net10.md +++ b/docs/in-depth/server/openapi/net10.md @@ -16,6 +16,8 @@ Follow [the basic instructions for OpenApi integration](https://learn.microsoft. builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(options => options.AddDatasyncTransformers()); + If you configured custom Datasync metadata property names with `AddDatasyncServices()`, the transformer uses those names when it marks system properties in generated schemas. + 4. Enable the middleware for serving the generated JSON document and the Swagger UI, also in `Program.cs`: app.MapOpenApi(); diff --git a/docs/in-depth/server/openapi/nswag.md b/docs/in-depth/server/openapi/nswag.md index dbcc3c00..35ac8d8a 100644 --- a/docs/in-depth/server/openapi/nswag.md +++ b/docs/in-depth/server/openapi/nswag.md @@ -15,7 +15,17 @@ Follow [the basic instructions for NSwag integration](https://github.com/RicoSut builder.Services.AddOpenApiDocument(options => { - options.AddDatasyncProcessors(); + options.AddDatasyncProcessor(); + }); + + If you configured custom Datasync metadata property names, pass a `TableDataPropertyMap` with the same property names to the processor so generated schemas use the same JSON property names: + + TableDataPropertyMap tableDataProperties = new TableDataPropertyMap() + .Map(id: "Key", updatedAt: "ChangedOn", version: "Token", deleted: "Removed"); + + builder.Services.AddOpenApiDocument(options => + { + options.AddDatasyncProcessor(tableDataProperties); }); 4. Enable the middleware for serving the generated JSON document and the Swagger UI, also in `Program.cs`: diff --git a/docs/in-depth/server/openapi/swashbuckle.md b/docs/in-depth/server/openapi/swashbuckle.md index 2642d0f9..60aa4a1f 100644 --- a/docs/in-depth/server/openapi/swashbuckle.md +++ b/docs/in-depth/server/openapi/swashbuckle.md @@ -14,8 +14,18 @@ Follow the [basic instructions for Swashbuckle integration](https://learn.micros options.AddDatasyncControllers(); }); + If you configured custom Datasync metadata property names, pass a `TableDataPropertyMap` with the same property names to the document filter so generated schemas use the same JSON property names: + + builder.Services.AddSwaggerGen(options => + { + TableDataPropertyMap tableDataProperties = new TableDataPropertyMap() + .Map(id: "Key", updatedAt: "ChangedOn", version: "Token", deleted: "Removed"); + + options.AddDatasyncControllers(tableDataProperties); + }); + !!! tip - The `AddDatasyncControllers()` method takes an optional `Assembly` that corresponds to the assembly that contains your table controllers. The `Assembly` parameter is only required if your table controllers are in a different project to the service. + `AddDatasyncControllers()` uses the calling assembly when it searches for table controllers. If your table controllers are in a different project, register `DatasyncDocumentFilter` directly and pass the controller assembly. 3. Enable the middleware for serving the generated JSON document and the Swagger UI, also in `Program.cs`: diff --git a/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/TableDataPropertyMap.cs b/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/TableDataPropertyMap.cs new file mode 100644 index 00000000..2db84bad --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.Abstractions/Tables/TableDataPropertyMap.cs @@ -0,0 +1,308 @@ +// 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 System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; + +namespace CommunityToolkit.Datasync.Server; + +/// +/// Configures the CLR property names used for Datasync system metadata. +/// +public sealed class TableDataPropertyMap +{ + private readonly ConcurrentDictionary accessors = []; + private string idPropertyName = nameof(ITableData.Id); + private string deletedPropertyName = nameof(ITableData.Deleted); + private string updatedAtPropertyName = nameof(ITableData.UpdatedAt); + private string versionPropertyName = nameof(ITableData.Version); + + /// + /// The CLR property name used for the entity identifier. + /// + public string IdPropertyName + { + get => this.idPropertyName; + set => SetPropertyName(ref this.idPropertyName, value); + } + + /// + /// The CLR property name used for the soft-delete flag. + /// + public string DeletedPropertyName + { + get => this.deletedPropertyName; + set => SetPropertyName(ref this.deletedPropertyName, value); + } + + /// + /// The CLR property name used for the last-updated timestamp. + /// + public string UpdatedAtPropertyName + { + get => this.updatedAtPropertyName; + set => SetPropertyName(ref this.updatedAtPropertyName, value); + } + + /// + /// The CLR property name used for the concurrency token. + /// + public string VersionPropertyName + { + get => this.versionPropertyName; + set => SetPropertyName(ref this.versionPropertyName, value); + } + + /// + /// Updates all system property names. + /// + /// The CLR property name used for the entity identifier. + /// The CLR property name used for the last-updated timestamp. + /// The CLR property name used for the concurrency token. + /// The CLR property name used for the soft-delete flag. + /// The current instance for chained configuration. + public TableDataPropertyMap Map(string id = nameof(ITableData.Id), string updatedAt = nameof(ITableData.UpdatedAt), string version = nameof(ITableData.Version), string deleted = nameof(ITableData.Deleted)) + { + IdPropertyName = id; + UpdatedAtPropertyName = updatedAt; + VersionPropertyName = version; + DeletedPropertyName = deleted; + return this; + } + + /// + /// Gets a typed metadata accessor for the provided entity type. + /// + /// The entity type. + /// A typed metadata accessor. + public TableDataAccessor GetAccessor() where TEntity : class + => (TableDataAccessor)this.accessors.GetOrAdd(typeof(TEntity), _ => new TableDataAccessor(this)); + + /// + /// Gets a metadata accessor for the provided entity type. + /// + /// The entity type. + /// A metadata accessor. + public TableDataAccessor GetAccessor(Type entityType) + => this.accessors.GetOrAdd(entityType, type => (TableDataAccessor)Activator.CreateInstance(typeof(TableDataAccessor<>).MakeGenericType(type), this)!); + + private void SetPropertyName(ref string field, string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + field = value; + this.accessors.Clear(); + } +} + +/// +/// Provides reflection-based access to Datasync system metadata properties. +/// +public abstract class TableDataAccessor +{ + /// + /// Creates a new metadata accessor. + /// + /// The entity type. + /// The property map to use. + protected TableDataAccessor(Type entityType, TableDataPropertyMap propertyMap) + { + EntityType = entityType; + IdProperty = GetRequiredProperty(entityType, propertyMap.IdPropertyName, typeof(string)); + DeletedProperty = GetRequiredProperty(entityType, propertyMap.DeletedPropertyName, typeof(bool)); + UpdatedAtProperty = GetRequiredProperty(entityType, propertyMap.UpdatedAtPropertyName, typeof(DateTimeOffset), typeof(DateTimeOffset?)); + VersionProperty = GetRequiredProperty(entityType, propertyMap.VersionPropertyName, typeof(byte[])); + } + + /// + /// The entity type handled by this accessor. + /// + public Type EntityType { get; } + + /// + /// The property used for the entity identifier. + /// + public PropertyInfo IdProperty { get; } + + /// + /// The property used for the soft-delete flag. + /// + public PropertyInfo DeletedProperty { get; } + + /// + /// The property used for the last-updated timestamp. + /// + public PropertyInfo UpdatedAtProperty { get; } + + /// + /// The property used for the concurrency token. + /// + public PropertyInfo VersionProperty { get; } + + /// + /// Gets the entity identifier. + /// + /// The entity. + /// The entity identifier. + public string? GetId(object entity) + => (string?)IdProperty.GetValue(entity); + + /// + /// Sets the entity identifier. + /// + /// The entity. + /// The entity identifier. + public void SetId(object entity, string id) + => IdProperty.SetValue(entity, id); + + /// + /// Gets the soft-delete flag. + /// + /// The entity. + /// true when the entity is soft-deleted. + public bool GetDeleted(object entity) + => (bool)(DeletedProperty.GetValue(entity) ?? false); + + /// + /// Sets the soft-delete flag. + /// + /// The entity. + /// The soft-delete flag. + public void SetDeleted(object entity, bool deleted) + => DeletedProperty.SetValue(entity, deleted); + + /// + /// Gets the last-updated timestamp. + /// + /// The entity. + /// The last-updated timestamp. + public DateTimeOffset? GetUpdatedAt(object entity) + { + object? value = UpdatedAtProperty.GetValue(entity); + return value is DateTimeOffset date ? date : null; + } + + /// + /// Sets the last-updated timestamp. + /// + /// The entity. + /// The last-updated timestamp. + public void SetUpdatedAt(object entity, DateTimeOffset? updatedAt) + { + object? value = UpdatedAtProperty.PropertyType == typeof(DateTimeOffset) ? updatedAt.GetValueOrDefault() : updatedAt; + UpdatedAtProperty.SetValue(entity, value); + } + + /// + /// Gets the concurrency token. + /// + /// The entity. + /// The concurrency token. + public byte[] GetVersion(object entity) + => (byte[]?)VersionProperty.GetValue(entity) ?? []; + + /// + /// Sets the concurrency token. + /// + /// The entity. + /// The concurrency token. + public void SetVersion(object entity, byte[] version) + => VersionProperty.SetValue(entity, version); + + private static PropertyInfo GetRequiredProperty(Type entityType, string name, params Type[] allowedTypes) + { + PropertyInfo property = entityType.GetProperty(name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy) + ?? throw new InvalidOperationException($"Entity type '{entityType.Name}' does not have a public '{name}' property."); + + if (!property.CanRead || !property.CanWrite) + { + throw new InvalidOperationException($"Entity property '{entityType.Name}.{name}' must be readable and writable."); + } + + if (!allowedTypes.Contains(property.PropertyType)) + { + string validTypes = string.Join(", ", allowedTypes.Select(t => t.Name)); + throw new InvalidOperationException($"Entity property '{entityType.Name}.{name}' must be one of: {validTypes}."); + } + + return property; + } +} + +/// +/// Provides typed access to Datasync system metadata properties. +/// +/// The entity type. +public sealed class TableDataAccessor : TableDataAccessor where TEntity : class +{ + /// + /// Creates a typed metadata accessor. + /// + /// The property map to use. + public TableDataAccessor(TableDataPropertyMap propertyMap) : base(typeof(TEntity), propertyMap) + { + IdExpression = CreatePropertyExpression(IdProperty); + DeletedExpression = CreatePropertyExpression(DeletedProperty); + UpdatedAtExpression = CreateNullableDateTimeOffsetExpression(UpdatedAtProperty); + } + + /// + /// An expression that reads the entity identifier. + /// + public Expression> IdExpression { get; } + + /// + /// An expression that reads the soft-delete flag. + /// + public Expression> DeletedExpression { get; } + + /// + /// An expression that reads the last-updated timestamp. + /// + public Expression> UpdatedAtExpression { get; } + + /// + /// Creates an expression that matches the entity identifier. + /// + /// The entity identifier. + /// An expression that matches the entity identifier. + public Expression> CreateIdEqualsExpression(string id) + { + ParameterExpression entity = Expression.Parameter(typeof(TEntity), "entity"); + MemberExpression property = Expression.Property(entity, IdProperty); + BinaryExpression body = Expression.Equal(property, Expression.Constant(id)); + return Expression.Lambda>(body, entity); + } + + /// + /// Creates an expression that matches non-deleted entities. + /// + /// An expression that matches non-deleted entities. + public Expression> CreateNotDeletedExpression() + { + ParameterExpression entity = Expression.Parameter(typeof(TEntity), "entity"); + MemberExpression property = Expression.Property(entity, DeletedProperty); + UnaryExpression body = Expression.Not(property); + return Expression.Lambda>(body, entity); + } + + private static Expression> CreatePropertyExpression(PropertyInfo property) + { + ParameterExpression entity = Expression.Parameter(typeof(TEntity), "entity"); + MemberExpression body = Expression.Property(entity, property); + return Expression.Lambda>(body, entity); + } + + private static Expression> CreateNullableDateTimeOffsetExpression(PropertyInfo property) + { + ParameterExpression entity = Expression.Parameter(typeof(TEntity), "entity"); + Expression body = Expression.Property(entity, property); + if (body.Type == typeof(DateTimeOffset)) + { + body = Expression.Convert(body, typeof(DateTimeOffset?)); + } + + return Expression.Lambda>(body, entity); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSharedTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSharedTableOptions.cs index 93ce806a..f90c3193 100644 --- a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSharedTableOptions.cs +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSharedTableOptions.cs @@ -19,10 +19,11 @@ public class CosmosSharedTableOptions : CosmosTableOptions whe /// 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) + /// The CLR property map used for Datasync system metadata. + public CosmosSharedTableOptions(string databaseId, string containerId, bool shouldUpdateTimestamp = true, TableDataPropertyMap? tableDataProperties = null) : base(databaseId, containerId, shouldUpdateTimestamp, tableDataProperties) { 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. /// @@ -37,7 +38,7 @@ public CosmosSharedTableOptions(string databaseId, string containerId, bool shou public override string GetPartitionKey(TEntity entity, out PartitionKey partitionKey) { partitionKey = new PartitionKey(Entity); - return entity.Id; + return TableDataProperties.GetAccessor().GetId(entity)!; } /// /// Parses the partition key from the id. Defaults to the entity type name. @@ -55,4 +56,4 @@ public override string ParsePartitionKey(string id, out PartitionKey partitionKe /// /// public override Expression> QueryablePredicate() => (e) => e.Entity == Entity; -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSingleTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSingleTableOptions.cs index 6d503ecb..baf746dc 100644 --- a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSingleTableOptions.cs +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSingleTableOptions.cs @@ -18,7 +18,8 @@ public class CosmosSingleTableOptions : CosmosTableOptions whe /// 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) + /// The CLR property map used for Datasync system metadata. + public CosmosSingleTableOptions(string databaseId, string containerId, bool shouldUpdateTimestamp = true, TableDataPropertyMap? tableDataProperties = null) : base(databaseId, containerId, shouldUpdateTimestamp, tableDataProperties) { } @@ -31,9 +32,10 @@ public CosmosSingleTableOptions(string databaseId, string containerId, bool shou public override string GetPartitionKey(TEntity entity, out PartitionKey partitionKey) { - partitionKey = new PartitionKey(entity.Id); + string id = TableDataProperties.GetAccessor().GetId(entity)!; + partitionKey = new PartitionKey(id); - return entity.Id; + return id; } /// /// @@ -51,4 +53,4 @@ public override string ParsePartitionKey(string id, out PartitionKey partitionKe /// /// public override Expression> QueryablePredicate() => (_) => true; -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableOptions.cs index a0324178..7684e618 100644 --- a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableOptions.cs +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableOptions.cs @@ -19,6 +19,8 @@ public abstract class CosmosTableOptions : ICosmosTableOptions public virtual string ContainerId { get; } /// public virtual bool ShouldUpdateTimestamp { get; } + /// + public virtual TableDataPropertyMap TableDataProperties { get; } /// /// Creates a new instance of the class. @@ -26,11 +28,13 @@ public abstract class CosmosTableOptions : ICosmosTableOptions /// 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) + bool shouldUpdateTimestamp = true, + TableDataPropertyMap? tableDataProperties = null) { if (string.IsNullOrWhiteSpace(databaseId)) { @@ -45,6 +49,7 @@ public CosmosTableOptions( DatabaseId = databaseId; ContainerId = containerId; ShouldUpdateTimestamp = shouldUpdateTimestamp; + TableDataProperties = tableDataProperties ?? new TableDataPropertyMap(); } /// public virtual Func IdGenerator => (_) => Guid.NewGuid().ToString(); diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableRepository.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableRepository.cs index 41b39fbc..7fdbc87b 100644 --- a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableRepository.cs +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableRepository.cs @@ -25,6 +25,10 @@ public class CosmosTableRepository : IRepository where TEntity /// The used for saving changes to the entity set. /// protected Container Container { get; } + /// + /// The accessor for Datasync system metadata. + /// + protected TableDataAccessor TableData { get; } /// public async ValueTask CountAsync(IQueryable queryable, CancellationToken cancellationToken = default) @@ -48,6 +52,7 @@ public CosmosTableRepository(CosmosClient client, ICosmosTableOptions o } Options = options ?? throw new ArgumentNullException(nameof(options)); + TableData = Options.TableDataProperties.GetAccessor(); Container = client.GetContainer(options.DatabaseId, options.ContainerId); @@ -81,7 +86,7 @@ internal void UpdateManagedProperties(TEntity entity) { if (Options.ShouldUpdateTimestamp) { - entity.UpdatedAt = DateTimeOffset.UtcNow; + TableData.SetUpdatedAt(entity, DateTimeOffset.UtcNow); } } @@ -116,9 +121,9 @@ public virtual ValueTask> AsQueryableAsync(CancellationToken /// public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(entity.Id)) + if (string.IsNullOrEmpty(TableData.GetId(entity))) { - entity.Id = Options.IdGenerator.Invoke(entity); + TableData.SetId(entity, Options.IdGenerator.Invoke(entity)); } string id = Options.GetPartitionKey(entity, out PartitionKey partitionKey); @@ -129,8 +134,8 @@ await WrapExceptionAsync(id, partitionKey, async () => ItemResponse response = await Container.CreateItemAsync(entity, partitionKey, cancellationToken: cancellationToken); - entity.ETag = response.Resource.ETag; - entity.UpdatedAt = response.Resource.UpdatedAt; + TableData.SetVersion(entity, TableData.GetVersion(response.Resource)); + TableData.SetUpdatedAt(entity, TableData.GetUpdatedAt(response.Resource)); }, cancellationToken).ConfigureAwait(false); } @@ -173,7 +178,7 @@ public virtual async ValueTask ReadAsync(string id, CancellationToken c throw new HttpException((int)HttpStatusCode.BadRequest, "ID is required"); } - if(Options.TryParsePartitionKey(id, out string entityId, out PartitionKey partitionKey) == false) + if (Options.TryParsePartitionKey(id, out string entityId, out PartitionKey partitionKey) == false) { throw new HttpException((int)HttpStatusCode.BadRequest, "ID is not in the correct format"); } @@ -185,7 +190,7 @@ public virtual async ValueTask ReadAsync(string id, CancellationToken c /// public virtual async ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(entity.Id)) + if (string.IsNullOrEmpty(TableData.GetId(entity))) { throw new HttpException((int)HttpStatusCode.BadRequest, "ID is required"); } @@ -211,4 +216,4 @@ await WrapExceptionAsync(id, partitionKey, async () => }, cancellationToken).ConfigureAwait(false); } #endregion -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/ICosmosTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/ICosmosTableOptions.cs index e4de5a3e..cec04cee 100644 --- a/src/CommunityToolkit.Datasync.Server.CosmosDb/ICosmosTableOptions.cs +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/ICosmosTableOptions.cs @@ -26,6 +26,10 @@ public interface ICosmosTableOptions where TEntity : CosmosTableData /// bool ShouldUpdateTimestamp { get; } /// + /// The CLR property map used for Datasync system metadata. + /// + TableDataPropertyMap TableDataProperties { get; } + /// /// Function to attempt to parse the partition key from the entity /// /// The entity passed from the controller to the repository ID @@ -55,4 +59,4 @@ public interface ICosmosTableOptions where TEntity : CosmosTableData /// Function to filter the dataset for the entity required for shared containers /// Expression> QueryablePredicate(); -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs index cf15b76e..3682f0cf 100644 --- a/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs +++ b/src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs @@ -25,6 +25,11 @@ public class EntityTableRepository : IRepository where TEntity /// protected DbSet DataSet { get; } + /// + /// The accessor for Datasync system metadata. + /// + protected TableDataAccessor TableData { get; } + /// /// If true, then UpdatedAt is updated by the repository. /// @@ -40,10 +45,12 @@ public class EntityTableRepository : IRepository where TEntity /// to store the entities." /// /// The database context representing the backend store. + /// /// Thrown if the is not registered in the . - public EntityTableRepository(DbContext context) + public EntityTableRepository(DbContext context, TableDataPropertyMap? tableDataProperties = null) { Context = context; + TableData = (tableDataProperties ?? new TableDataPropertyMap()).GetAccessor(); try { DataSet = context.Set(); @@ -55,8 +62,8 @@ public EntityTableRepository(DbContext context) throw new ArgumentException($"Unregistered entity type {typeof(TEntity).Name}", nameof(context)); } - this.shouldUpdateUpdatedAt = Attribute.IsDefined(typeof(TEntity).GetProperty(nameof(ITableData.UpdatedAt))!, typeof(UpdatedByRepositoryAttribute)); - this.shouldUpdateVersion = Attribute.IsDefined(typeof(TEntity).GetProperty(nameof(ITableData.Version))!, typeof(UpdatedByRepositoryAttribute)); + this.shouldUpdateUpdatedAt = Attribute.IsDefined(TableData.UpdatedAtProperty, typeof(UpdatedByRepositoryAttribute)); + this.shouldUpdateVersion = Attribute.IsDefined(TableData.VersionProperty, typeof(UpdatedByRepositoryAttribute)); } /// @@ -76,7 +83,7 @@ public EntityTableRepository(DbContext context) /// A to observe. /// A task that returns an untracked version of the entity when complete. protected Task GetEntityAsync(string id, CancellationToken cancellationToken = default) - => DataSet.AsNoTracking().SingleAsync(x => x.Id == id, cancellationToken); + => DataSet.AsNoTracking().SingleAsync(TableData.CreateIdEqualsExpression(id), cancellationToken); /// /// Updates the managed properties for this entity if required. @@ -86,12 +93,12 @@ internal void UpdateManagedProperties(TEntity entity) { if (this.shouldUpdateUpdatedAt) { - entity.UpdatedAt = DateTimeOffset.UtcNow; + TableData.SetUpdatedAt(entity, DateTimeOffset.UtcNow); } if (this.shouldUpdateVersion) { - entity.Version = VersionGenerator.Invoke(); + TableData.SetVersion(entity, VersionGenerator.Invoke()); } } @@ -130,14 +137,16 @@ public virtual ValueTask> AsQueryableAsync(CancellationToken [SuppressMessage("Performance", "CA1827:Do not use Count() or LongCount() when Any() can be used", Justification = "Not all EF providers support Any()")] public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(entity.Id)) + string? entityId = TableData.GetId(entity); + if (string.IsNullOrEmpty(entityId)) { - entity.Id = IdGenerator.Invoke(entity); + entityId = IdGenerator.Invoke(entity); + TableData.SetId(entity, entityId); } - await WrapExceptionAsync(entity.Id, async () => + await WrapExceptionAsync(entityId, async () => { - TEntity? existingEntity = await DataSet.FindAsync([entity.Id], cancellationToken).ConfigureAwait(false); + TEntity? existingEntity = await DataSet.FindAsync([entityId], cancellationToken).ConfigureAwait(false); if (existingEntity is not null) { throw new HttpException((int)HttpStatusCode.Conflict) { Payload = existingEntity }; @@ -162,7 +171,7 @@ await WrapExceptionAsync(id, async () => TEntity storedEntity = await DataSet.FindAsync([id], cancellationToken).ConfigureAwait(false) ?? throw new HttpException((int)HttpStatusCode.NotFound); - if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version)) + if (version?.Length > 0 && !TableData.GetVersion(storedEntity).SequenceEqual(version)) { throw new HttpException((int)HttpStatusCode.PreconditionFailed) { Payload = await GetEntityAsync(id, cancellationToken).ConfigureAwait(false) }; } @@ -180,7 +189,7 @@ public virtual async ValueTask ReadAsync(string id, CancellationToken c throw new HttpException((int)HttpStatusCode.BadRequest, "ID is required"); } - TEntity entity = await DataSet.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id, cancellationToken).ConfigureAwait(false) + TEntity entity = await DataSet.AsNoTracking().SingleOrDefaultAsync(TableData.CreateIdEqualsExpression(id), cancellationToken).ConfigureAwait(false) ?? throw new HttpException((int)HttpStatusCode.NotFound); return entity; @@ -189,19 +198,20 @@ public virtual async ValueTask ReadAsync(string id, CancellationToken c /// public virtual async ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(entity.Id)) + string? entityId = TableData.GetId(entity); + if (string.IsNullOrEmpty(entityId)) { throw new HttpException((int)HttpStatusCode.BadRequest, "ID is required"); } - await WrapExceptionAsync(entity.Id, async () => + await WrapExceptionAsync(entityId, async () => { - TEntity storedEntity = await DataSet.FindAsync([entity.Id], cancellationToken).ConfigureAwait(false) + TEntity storedEntity = await DataSet.FindAsync([entityId], cancellationToken).ConfigureAwait(false) ?? throw new HttpException((int)HttpStatusCode.NotFound); - if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version)) + if (version?.Length > 0 && !TableData.GetVersion(storedEntity).SequenceEqual(version)) { - throw new HttpException((int)HttpStatusCode.PreconditionFailed) { Payload = await GetEntityAsync(entity.Id, cancellationToken).ConfigureAwait(false) }; + throw new HttpException((int)HttpStatusCode.PreconditionFailed) { Payload = await GetEntityAsync(entityId, cancellationToken).ConfigureAwait(false) }; } UpdateManagedProperties(entity); @@ -222,4 +232,4 @@ public virtual async ValueTask> ToListAsync(IQueryable q return await query.ToListAsync(cancellationToken); } #endregion -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.InMemory/InMemoryRepository.cs b/src/CommunityToolkit.Datasync.Server.InMemory/InMemoryRepository.cs index d3c9baa9..c286e000 100644 --- a/src/CommunityToolkit.Datasync.Server.InMemory/InMemoryRepository.cs +++ b/src/CommunityToolkit.Datasync.Server.InMemory/InMemoryRepository.cs @@ -18,23 +18,31 @@ namespace CommunityToolkit.Datasync.Server.InMemory; public class InMemoryRepository : IRepository where TEntity : InMemoryTableData { private readonly ConcurrentDictionary _entities = new(); + private readonly TableDataAccessor tableData; /// /// Creates a new empty repository instance. /// - public InMemoryRepository() + public InMemoryRepository(TableDataPropertyMap? tableDataProperties = null) { + this.tableData = (tableDataProperties ?? new TableDataPropertyMap()).GetAccessor(); } /// /// Creates a new populated repository instance. /// /// A set of entities to be stored in the repository. - public InMemoryRepository(IEnumerable entities) + /// + public InMemoryRepository(IEnumerable entities, TableDataPropertyMap? tableDataProperties = null) + : this(tableDataProperties) { foreach (TEntity entity in entities) { - entity.Id ??= IdGenerator.Invoke(entity); + if (this.tableData.GetId(entity) is null) + { + this.tableData.SetId(entity, IdGenerator.Invoke(entity)); + } + StoreEntity(entity); } } @@ -104,9 +112,9 @@ internal void RemoveEntity(string id) /// The entity to store in the repository. internal void StoreEntity(TEntity entity) { - entity.UpdatedAt = DateTimeOffset.UtcNow; - entity.Version = VersionGenerator.Invoke(); - this._entities[entity.Id] = Disconnect(entity); + this.tableData.SetUpdatedAt(entity, DateTimeOffset.UtcNow); + this.tableData.SetVersion(entity, VersionGenerator.Invoke()); + this._entities[this.tableData.GetId(entity)!] = Disconnect(entity); } /// @@ -132,12 +140,14 @@ public virtual ValueTask> AsQueryableAsync(CancellationToken public virtual ValueTask CreateAsync(TEntity entity, CancellationToken cancellationToken = default) { ThrowExceptionIfSet(); - if (string.IsNullOrEmpty(entity.Id)) + string? entityId = this.tableData.GetId(entity); + if (string.IsNullOrEmpty(entityId)) { - entity.Id = IdGenerator.Invoke(entity); + entityId = IdGenerator.Invoke(entity); + this.tableData.SetId(entity, entityId); } - if (this._entities.TryGetValue(entity.Id, out TEntity? storedEntity)) + if (this._entities.TryGetValue(entityId, out TEntity? storedEntity)) { throw new HttpException(HttpStatusCodes.Status409Conflict) { Payload = Disconnect(storedEntity) }; } @@ -160,7 +170,7 @@ public virtual ValueTask DeleteAsync(string id, byte[]? version = null, Cancella throw new HttpException(HttpStatusCodes.Status404NotFound); } - if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version)) + if (version?.Length > 0 && !this.tableData.GetVersion(storedEntity).SequenceEqual(version)) { throw new HttpException(HttpStatusCodes.Status412PreconditionFailed) { Payload = Disconnect(storedEntity) }; } @@ -190,17 +200,18 @@ public virtual ValueTask ReadAsync(string id, CancellationToken cancell public virtual ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default) { ThrowExceptionIfSet(); - if (string.IsNullOrEmpty(entity.Id)) + string? entityId = this.tableData.GetId(entity); + if (string.IsNullOrEmpty(entityId)) { throw new HttpException(HttpStatusCodes.Status400BadRequest); } - if (!this._entities.TryGetValue(entity.Id, out TEntity? storedEntity)) + if (!this._entities.TryGetValue(entityId, out TEntity? storedEntity)) { throw new HttpException(HttpStatusCodes.Status404NotFound); } - if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version)) + if (version?.Length > 0 && !this.tableData.GetVersion(storedEntity).SequenceEqual(version)) { throw new HttpException(HttpStatusCodes.Status412PreconditionFailed) { Payload = Disconnect(storedEntity) }; } @@ -209,4 +220,4 @@ public virtual ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, Ca return ValueTask.CompletedTask; } #endregion -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.LiteDb/LiteDbRepository.cs b/src/CommunityToolkit.Datasync.Server.LiteDb/LiteDbRepository.cs index cfa7675b..e9590ca5 100644 --- a/src/CommunityToolkit.Datasync.Server.LiteDb/LiteDbRepository.cs +++ b/src/CommunityToolkit.Datasync.Server.LiteDb/LiteDbRepository.cs @@ -14,6 +14,7 @@ namespace CommunityToolkit.Datasync.Server.LiteDb; public class LiteDbRepository : IRepository where TEntity : LiteDbTableData { private readonly LiteDatabase connection; + private readonly TableDataAccessor tableData; // On a web server (like this is normally used in), we expect the LiteDatabase to be // a singleton and we want to ensure that writes to the database are serialized. We @@ -28,7 +29,8 @@ public class LiteDbRepository : IRepository where TEntity : Li /// The collection name is based on the entity type. /// /// The connection to use for storing entities. - public LiteDbRepository(LiteDatabase dbConnection) : this(dbConnection, typeof(TEntity).Name.ToLowerInvariant() + "s") + /// + public LiteDbRepository(LiteDatabase dbConnection, TableDataPropertyMap? tableDataProperties = null) : this(dbConnection, typeof(TEntity).Name.ToLowerInvariant() + "s", tableDataProperties) { } @@ -38,11 +40,13 @@ public LiteDbRepository(LiteDatabase dbConnection) : this(dbConnection, typeof(T /// /// The connection to use for storing entities. /// The name of the collection to use for storing the entities. - public LiteDbRepository(LiteDatabase dbConnection, string collectionName) + /// + public LiteDbRepository(LiteDatabase dbConnection, string collectionName, TableDataPropertyMap? tableDataProperties = null) { this.connection = dbConnection; + this.tableData = (tableDataProperties ?? new TableDataPropertyMap()).GetAccessor(); Collection = this.connection.GetCollection(collectionName); - _ = Collection.EnsureIndex(x => x.UpdatedAt); + _ = Collection.EnsureIndex(this.tableData.UpdatedAtExpression); } /// @@ -66,8 +70,8 @@ public LiteDbRepository(LiteDatabase dbConnection, string collectionName) /// The entity to update. protected void UpdateEntity(TEntity entity) { - entity.UpdatedAt = DateTimeOffset.UtcNow; - entity.Version = VersionGenerator.Invoke(); + this.tableData.SetUpdatedAt(entity, DateTimeOffset.UtcNow); + this.tableData.SetVersion(entity, VersionGenerator.Invoke()); } /// @@ -109,14 +113,16 @@ public virtual ValueTask> AsQueryableAsync(CancellationToken /// public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(entity.Id)) + string? entityId = this.tableData.GetId(entity); + if (string.IsNullOrEmpty(entityId)) { - entity.Id = IdGenerator.Invoke(entity); + entityId = IdGenerator.Invoke(entity); + this.tableData.SetId(entity, entityId); } await ExecuteOnLockedCollectionAsync(() => { - TEntity existingEntity = Collection.FindById(entity.Id); + TEntity existingEntity = Collection.FindOne(this.tableData.CreateIdEqualsExpression(entityId)); if (existingEntity != null) { throw new HttpException(HttpStatusCodes.Status409Conflict) { Payload = existingEntity }; @@ -134,8 +140,8 @@ public virtual async ValueTask DeleteAsync(string id, byte[]? version = null, Ca await ExecuteOnLockedCollectionAsync(() => { - TEntity storedEntity = Collection.FindById(id) ?? throw new HttpException(HttpStatusCodes.Status404NotFound); - if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version)) + TEntity storedEntity = Collection.FindOne(this.tableData.CreateIdEqualsExpression(id)) ?? throw new HttpException(HttpStatusCodes.Status404NotFound); + if (version?.Length > 0 && !this.tableData.GetVersion(storedEntity).SequenceEqual(version)) { throw new HttpException(HttpStatusCodes.Status412PreconditionFailed) { Payload = storedEntity }; } @@ -149,19 +155,20 @@ public virtual ValueTask ReadAsync(string id, CancellationToken cancell { CheckIdIsValid(id); - TEntity entity = Collection.FindById(id) ?? throw new HttpException(HttpStatusCodes.Status404NotFound); + TEntity entity = Collection.FindOne(this.tableData.CreateIdEqualsExpression(id)) ?? throw new HttpException(HttpStatusCodes.Status404NotFound); return ValueTask.FromResult(entity); } /// public virtual async ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default) { - CheckIdIsValid(entity.Id); + string? entityId = this.tableData.GetId(entity); + CheckIdIsValid(entityId!); await ExecuteOnLockedCollectionAsync(() => { - TEntity storedEntity = Collection.FindById(entity.Id) ?? throw new HttpException(HttpStatusCodes.Status404NotFound); - if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version)) + TEntity storedEntity = Collection.FindOne(this.tableData.CreateIdEqualsExpression(entityId!)) ?? throw new HttpException(HttpStatusCodes.Status404NotFound); + if (version?.Length > 0 && !this.tableData.GetVersion(storedEntity).SequenceEqual(version)) { throw new HttpException(HttpStatusCodes.Status412PreconditionFailed) { Payload = storedEntity }; } @@ -170,4 +177,4 @@ await ExecuteOnLockedCollectionAsync(() => _ = Collection.Update(entity); }, cancellationToken); } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.MongoDB/MongoDBRepository.cs b/src/CommunityToolkit.Datasync.Server.MongoDB/MongoDBRepository.cs index 331ea804..a3ac01b4 100644 --- a/src/CommunityToolkit.Datasync.Server.MongoDB/MongoDBRepository.cs +++ b/src/CommunityToolkit.Datasync.Server.MongoDB/MongoDBRepository.cs @@ -14,6 +14,8 @@ namespace CommunityToolkit.Datasync.Server.MongoDB; /// The entity type to store in the database. public class MongoDBRepository : IRepository where TEntity : MongoTableData { + private readonly TableDataAccessor tableData; + /// /// Creates a new using the provided MongoDB database. /// @@ -21,7 +23,8 @@ public class MongoDBRepository : IRepository where TEntity : M /// The collection name is based on the entity type. /// /// The to use for storing entities. - public MongoDBRepository(IMongoDatabase database) : this(database.GetCollection(typeof(TEntity).Name.ToLowerInvariant() + "s")) + /// + public MongoDBRepository(IMongoDatabase database, TableDataPropertyMap? tableDataProperties = null) : this(database.GetCollection(typeof(TEntity).Name.ToLowerInvariant() + "s"), tableDataProperties) { } @@ -30,9 +33,11 @@ public MongoDBRepository(IMongoDatabase database) : this(database.GetCollection< /// and collection name. /// /// The to use for storing entities. - public MongoDBRepository(IMongoCollection collection) + /// + public MongoDBRepository(IMongoCollection collection, TableDataPropertyMap? tableDataProperties = null) { Collection = collection; + this.tableData = (tableDataProperties ?? new TableDataPropertyMap()).GetAccessor(); // TODO: Ensure that there is an index on the right properties. } @@ -57,8 +62,8 @@ public MongoDBRepository(IMongoCollection collection) /// The entity to update. protected void UpdateEntity(TEntity entity) { - entity.UpdatedAt = DateTimeOffset.UtcNow; - entity.Version = VersionGenerator.Invoke(); + this.tableData.SetUpdatedAt(entity, DateTimeOffset.UtcNow); + this.tableData.SetVersion(entity, VersionGenerator.Invoke()); } /// @@ -80,7 +85,7 @@ protected static void CheckIdIsValid(string id) /// The ID of the document to find. /// The filter definition to find the document. protected FilterDefinition GetFilterById(string id) - => Builders.Filter.Eq(x => x.Id, id); + => Builders.Filter.Eq(this.tableData.IdProperty.Name, id); /// /// Returns the document with the provided ID, or null if it doesn't exist. @@ -100,12 +105,14 @@ public virtual ValueTask> AsQueryableAsync(CancellationToken /// public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(entity.Id)) + string? entityId = this.tableData.GetId(entity); + if (string.IsNullOrEmpty(entityId)) { - entity.Id = IdGenerator.Invoke(entity); + entityId = IdGenerator.Invoke(entity); + this.tableData.SetId(entity, entityId); } - TEntity? existingEntity = await FindDocumentByIdAsync(entity.Id, cancellationToken).ConfigureAwait(false); + TEntity? existingEntity = await FindDocumentByIdAsync(entityId, cancellationToken).ConfigureAwait(false); if (existingEntity is not null) { throw new HttpException(HttpStatusCodes.Status409Conflict) { Payload = existingEntity }; @@ -122,7 +129,7 @@ public virtual async ValueTask DeleteAsync(string id, byte[]? version = null, Ca TEntity storedEntity = await FindDocumentByIdAsync(id, cancellationToken).ConfigureAwait(false) ?? throw new HttpException(HttpStatusCodes.Status404NotFound); - if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version)) + if (version?.Length > 0 && !this.tableData.GetVersion(storedEntity).SequenceEqual(version)) { throw new HttpException(HttpStatusCodes.Status412PreconditionFailed) { Payload = storedEntity }; } @@ -146,21 +153,22 @@ public virtual async ValueTask ReadAsync(string id, CancellationToken c /// public virtual async ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default) { - CheckIdIsValid(entity.Id); + string? entityId = this.tableData.GetId(entity); + CheckIdIsValid(entityId!); - TEntity storedEntity = await FindDocumentByIdAsync(entity.Id, cancellationToken).ConfigureAwait(false) + TEntity storedEntity = await FindDocumentByIdAsync(entityId!, cancellationToken).ConfigureAwait(false) ?? throw new HttpException(HttpStatusCodes.Status404NotFound); - if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version)) + if (version?.Length > 0 && !this.tableData.GetVersion(storedEntity).SequenceEqual(version)) { throw new HttpException(HttpStatusCodes.Status412PreconditionFailed) { Payload = storedEntity }; } UpdateEntity(entity); ReplaceOptions options = new() { IsUpsert = false }; - ReplaceOneResult result = await Collection.ReplaceOneAsync(GetFilterById(entity.Id), entity, options, cancellationToken); + ReplaceOneResult result = await Collection.ReplaceOneAsync(GetFilterById(entityId!), entity, options, cancellationToken); if (result.IsModifiedCountAvailable && result.ModifiedCount == 0) { throw new HttpException(HttpStatusCodes.Status404NotFound); } } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.NSwag/DatasyncOperationProcessor.cs b/src/CommunityToolkit.Datasync.Server.NSwag/DatasyncOperationProcessor.cs index 3f873b63..14af1dcf 100644 --- a/src/CommunityToolkit.Datasync.Server.NSwag/DatasyncOperationProcessor.cs +++ b/src/CommunityToolkit.Datasync.Server.NSwag/DatasyncOperationProcessor.cs @@ -2,6 +2,7 @@ // 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.Filters; using NJsonSchema; using NSwag; @@ -9,6 +10,7 @@ using NSwag.Generation.Processors.Contexts; using System.Net; using System.Reflection; +using System.Text.Json; namespace CommunityToolkit.Datasync.Server.NSwag; @@ -17,6 +19,17 @@ namespace CommunityToolkit.Datasync.Server.NSwag; /// public class DatasyncOperationProcessor : IOperationProcessor { + private readonly TableDataPropertyMap tableDataProperties; + + /// + /// Creates a new . + /// + /// The CLR property map used for Datasync system metadata. + public DatasyncOperationProcessor(TableDataPropertyMap? tableDataProperties = null) + { + this.tableDataProperties = tableDataProperties ?? new TableDataPropertyMap(); + } + /// Processes the specified method information. /// The processor context. /// true if the operation should be added to the Swagger specification. @@ -58,14 +71,14 @@ internal static Type GetTableEntityType(Type controllerType) => controllerType.BaseType?.GetGenericArguments().FirstOrDefault() ?? throw new ArgumentException("Unable to retrieve generic entity type"); - private static void ProcessDatasyncOperation(OperationProcessorContext context) + private void ProcessDatasyncOperation(OperationProcessorContext context) { OpenApiOperation operation = context.OperationDescription.Operation; string method = context.OperationDescription.Method; string path = context.OperationDescription.Path; Type entityType = GetTableEntityType(context.ControllerType); JsonSchema entitySchemaRef = GetEntityReference(context, entityType); - AddMissingSchemaProperties(entitySchemaRef.Reference); + AddMissingSchemaPropertiesCore(entitySchemaRef.Reference); if (method.Equals("DELETE", StringComparison.InvariantCultureIgnoreCase)) { @@ -111,35 +124,43 @@ private static void ProcessDatasyncOperation(OperationProcessorContext context) } internal static void AddMissingSchemaProperties(JsonSchema? schema) + => new DatasyncOperationProcessor().AddMissingSchemaPropertiesCore(schema); + + internal void AddMissingSchemaPropertiesCore(JsonSchema? schema) { if (schema is null) { return; } - if (schema.Properties.ContainsKey("id") && schema.Properties.ContainsKey("updatedAt") && schema.Properties.ContainsKey("version")) + JsonNamingPolicy namingPolicy = JsonNamingPolicy.CamelCase; + string idPropertyName = namingPolicy.ConvertName(this.tableDataProperties.IdPropertyName); + string updatedAtPropertyName = namingPolicy.ConvertName(this.tableDataProperties.UpdatedAtPropertyName); + string versionPropertyName = namingPolicy.ConvertName(this.tableDataProperties.VersionPropertyName); + + if (schema.Properties.ContainsKey(idPropertyName) && schema.Properties.ContainsKey(updatedAtPropertyName) && schema.Properties.ContainsKey(versionPropertyName)) { // Nothing to do - the correct properties are already in the schma. return; } - _ = schema.Properties.TryAdd("id", new JsonSchemaProperty + _ = schema.Properties.TryAdd(idPropertyName, new JsonSchemaProperty { Type = JsonObjectType.String, Description = "The globally unique ID for the entity", IsRequired = true }); - _ = schema.Properties.TryAdd("updatedAt", new JsonSchemaProperty - { + _ = schema.Properties.TryAdd(updatedAtPropertyName, new JsonSchemaProperty + { Type = JsonObjectType.String, Description = "The ISO-8601 date/time string describing the last time the entity was updated with ms accuracy.", IsRequired = false }); - _ = schema.Properties.TryAdd("version", new JsonSchemaProperty + _ = schema.Properties.TryAdd(versionPropertyName, new JsonSchemaProperty { - Type = JsonObjectType.String, - Description = "An opaque string that changes whenever the entity changes.", - IsRequired = false + Type = JsonObjectType.String, + Description = "An opaque string that changes whenever the entity changes.", + IsRequired = false }); return; @@ -203,4 +224,4 @@ private static JsonSchema CreateListSchema(JsonSchema entitySchema, string entit }; return listSchemaRef; } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.NSwag/DatasyncSchemaProcessor.cs b/src/CommunityToolkit.Datasync.Server.NSwag/DatasyncSchemaProcessor.cs index 8921917b..1ae24397 100644 --- a/src/CommunityToolkit.Datasync.Server.NSwag/DatasyncSchemaProcessor.cs +++ b/src/CommunityToolkit.Datasync.Server.NSwag/DatasyncSchemaProcessor.cs @@ -2,8 +2,10 @@ // 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 NJsonSchema; using NJsonSchema.Generation; +using System.Text.Json; namespace CommunityToolkit.Datasync.Server.NSwag; @@ -15,7 +17,23 @@ public class DatasyncSchemaProcessor : ISchemaProcessor /// /// List of the system properties within the interface. /// - private static readonly string[] systemProperties = ["deleted", "updatedAt", "version"]; + private readonly string[] systemProperties; + + /// + /// Creates a new . + /// + /// The CLR property map used for Datasync system metadata. + public DatasyncSchemaProcessor(TableDataPropertyMap? tableDataProperties = null) + { + tableDataProperties ??= new TableDataPropertyMap(); + JsonNamingPolicy namingPolicy = JsonNamingPolicy.CamelCase; + this.systemProperties = + [ + namingPolicy.ConvertName(tableDataProperties.DeletedPropertyName), + namingPolicy.ConvertName(tableDataProperties.UpdatedAtPropertyName), + namingPolicy.ConvertName(tableDataProperties.VersionPropertyName) + ]; + } /// /// Processes each schema in turn, doing required modifications. @@ -27,11 +45,11 @@ public void Process(SchemaProcessorContext context) { foreach (KeyValuePair prop in context.Schema.Properties) { - if (systemProperties.Contains(prop.Key)) + if (this.systemProperties.Contains(prop.Key)) { prop.Value.IsReadOnly = true; } } } } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.NSwag/OpenApiExtensions.cs b/src/CommunityToolkit.Datasync.Server.NSwag/OpenApiExtensions.cs index a5cc6905..9c611da9 100644 --- a/src/CommunityToolkit.Datasync.Server.NSwag/OpenApiExtensions.cs +++ b/src/CommunityToolkit.Datasync.Server.NSwag/OpenApiExtensions.cs @@ -2,6 +2,7 @@ // 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 NJsonSchema; using NSwag; using NSwag.Generation.AspNetCore; @@ -24,9 +25,18 @@ public static partial class OpenApiDatasyncExtensions /// /// The NSwag settings object. public static void AddDatasyncProcessor(this AspNetCoreOpenApiDocumentGeneratorSettings settings) + => settings.AddDatasyncProcessor(null); + + /// + /// Adds the operation and schema processors that are used for the Datasync + /// service. + /// + /// The NSwag settings object. + /// The CLR property map used for Datasync system metadata. + public static void AddDatasyncProcessor(this AspNetCoreOpenApiDocumentGeneratorSettings settings, TableDataPropertyMap? tableDataProperties) { - settings.OperationProcessors.Add(new DatasyncOperationProcessor()); - settings.SchemaSettings.SchemaProcessors.Add(new DatasyncSchemaProcessor()); + settings.OperationProcessors.Add(new DatasyncOperationProcessor(tableDataProperties)); + settings.SchemaSettings.SchemaProcessors.Add(new DatasyncSchemaProcessor(tableDataProperties)); } /// @@ -131,4 +141,4 @@ internal static void AddODataQueryParameters(this OpenApiOperation operation) operation.AddODataQueryParameter("$top", JsonObjectType.Integer, "The number of items in the list to return for paging support."); operation.AddODataQueryParameter("__includedeleted", JsonObjectType.Boolean, "If true, soft-deleted items are returned as well as non-deleted items."); } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs index 49717ebc..bc4ac097 100644 --- a/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs +++ b/src/CommunityToolkit.Datasync.Server.OpenApi/DatasyncOperationTransformer.cs @@ -2,6 +2,7 @@ // 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.Filters; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Controllers; @@ -15,11 +16,22 @@ namespace CommunityToolkit.Datasync.Server.OpenApi; /// public class DatasyncOperationTransformer : IOpenApiOperationTransformer { + private readonly TableDataPropertyMap tableDataProperties; + /// /// The list of processed entity names (which are those we have added to the schema already). /// private readonly List processedEntityNames = []; + /// + /// Creates a new . + /// + /// The Datasync service options. + public DatasyncOperationTransformer(IDatasyncServiceOptions? options = null) + { + this.tableDataProperties = options?.TableDataProperties ?? new TableDataPropertyMap(); + } + /// public async Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { @@ -88,7 +100,7 @@ internal async Task AddSchemaIfNotProcessedAsync(OpenApiOperationTransformerCont } OpenApiSchema schema = await context.GetOrCreateSchemaAsync(entityType, cancellationToken: cancellationToken).ConfigureAwait(false); - schema.SetSystemPropertiesReadonly(); + schema.SetSystemPropertiesReadonly(this.tableDataProperties); _ = context.Document!.AddComponent(entityType.Name, schema); Type pagedEntityType = typeof(PagedResult<>).MakeGenericType(entityType); @@ -270,4 +282,4 @@ internal async Task TransformReplaceAsync(OpenApiOperation operation, OpenApiOpe operation.Responses.AddEntityResponse(StatusCodes.Status409Conflict, schemaRef, includeConditionalHeaders: true); operation.Responses.AddEntityResponse(StatusCodes.Status412PreconditionFailed, schemaRef, includeConditionalHeaders: true); } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs b/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs index fefcba0e..dd8fce9c 100644 --- a/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs +++ b/src/CommunityToolkit.Datasync.Server.OpenApi/InternalExtensions.cs @@ -2,11 +2,13 @@ // 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.OpenApi; using Microsoft.AspNetCore.WebUtilities; using Microsoft.OpenApi; using System.Net.Mime; using System.Runtime.CompilerServices; +using System.Text.Json; namespace CommunityToolkit.Datasync.Server.OpenApi; @@ -23,7 +25,7 @@ internal static class InternalExtensions internal static void AddRequestBody(this OpenApiOperation operation, IOpenApiSchema bodySchema) { operation.RequestBody ??= new OpenApiRequestBody() - { + { Content = new Dictionary(), Description = "The entity to process.", Required = true @@ -229,11 +231,21 @@ internal static void SetSchemaPropertyReadonly(this OpenApiSchema schema, string /// Sets the system properties within the schema to be read-only /// /// The schema to adjust. - internal static void SetSystemPropertiesReadonly(this OpenApiSchema schema) + /// The CLR property map used for Datasync system metadata. + internal static void SetSystemPropertiesReadonly(this OpenApiSchema schema, TableDataPropertyMap tableDataProperties) + { + foreach (string propertyName in tableDataProperties.GetJsonSystemPropertyNames()) + { + schema.SetSchemaPropertyReadonly(propertyName); + } + } + + internal static IEnumerable GetJsonSystemPropertyNames(this TableDataPropertyMap tableDataProperties) { - schema.SetSchemaPropertyReadonly("updatedAt"); - schema.SetSchemaPropertyReadonly("version"); - schema.SetSchemaPropertyReadonly("deleted"); + JsonNamingPolicy namingPolicy = JsonNamingPolicy.CamelCase; + yield return namingPolicy.ConvertName(tableDataProperties.UpdatedAtPropertyName); + yield return namingPolicy.ConvertName(tableDataProperties.VersionPropertyName); + yield return namingPolicy.ConvertName(tableDataProperties.DeletedPropertyName); } /// @@ -269,4 +281,4 @@ internal static void Upsert(this OpenApiResponses responses, string key, OpenApi _ = responses.Remove(key); responses.Add(key, response); } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs index 191bd250..a1462177 100644 --- a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs +++ b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncDocumentFilter.cs @@ -2,6 +2,7 @@ // 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.Filters; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.OpenApi; @@ -20,8 +21,11 @@ namespace CommunityToolkit.Datasync.Server.Swashbuckle; /// Creates a new . /// /// The assembly to query for TableController instances, if any. If none is provided, the calling assembly is queried. -public class DatasyncDocumentFilter(Assembly? assemblyToQuery = null) : IDocumentFilter +/// The CLR property map used for Datasync system metadata. +public class DatasyncDocumentFilter(Assembly? assemblyToQuery = null, TableDataPropertyMap? tableDataProperties = null) : IDocumentFilter { + private readonly TableDataPropertyMap tableDataProperties = tableDataProperties ?? new TableDataPropertyMap(); + // The list of operation types. private enum OpType { @@ -78,7 +82,7 @@ internal void AddSchemaReferenceForEntity(DocumentFilterContext context, OpenApi _ = context.SchemaGenerator.GenerateSchema(entityType, context.SchemaRepository); } - context.SchemaRepository.Schemas[entityType.Name].MakeSystemPropertiesReadonly(); + context.SchemaRepository.Schemas[entityType.Name].MakeSystemPropertiesReadonly(this.tableDataProperties.GetJsonSystemPropertyNames()); _ = document.AddComponent(entityType.Name, context.SchemaRepository.Schemas[entityType.Name]); this.processedEntityNames.Add(entityType.Name); } @@ -228,7 +232,7 @@ internal static bool TryGetTableEntityType(Type controllerType, out Type? entity /// The type of the controller being used. /// true if the Api description represents the controller. internal static bool IsApiDescriptionForController(ApiDescription description, Type controllerType) - => description.TryGetMethodInfo(out MethodInfo methodInfo) + => description.TryGetMethodInfo(out MethodInfo methodInfo) && methodInfo.ReflectedType == controllerType && (methodInfo.Name.Equals(queryMethod) || methodInfo.Name.Equals(createMethod)); @@ -280,4 +284,4 @@ internal class Page /// public Uri? NextLink { get; } } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs index f0f91728..fe6d60e3 100644 --- a/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs +++ b/src/CommunityToolkit.Datasync.Server.Swashbuckle/DatasyncOperationExtensions.cs @@ -2,8 +2,10 @@ // 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.OpenApi; using System; +using System.Text.Json; namespace CommunityToolkit.Datasync.Server.Swashbuckle; @@ -137,14 +139,24 @@ internal static void AddConflictResponse(this OpenApiOperation operation, IOpenA /// Makes the system properties in the schema read-only. /// /// The to edit. - public static void MakeSystemPropertiesReadonly(this IOpenApiSchema schema) + /// The JSON property names to mark as read-only. + public static void MakeSystemPropertiesReadonly(this IOpenApiSchema schema, IEnumerable? systemProperties = null) { + string[] properties = [.. systemProperties ?? SystemProperties]; foreach (KeyValuePair property in schema.Properties!) { - if (SystemProperties.Contains(property.Key)) + if (properties.Contains(property.Key)) { ((OpenApiSchema)property.Value).ReadOnly = true; } } } -} + + internal static IEnumerable GetJsonSystemPropertyNames(this TableDataPropertyMap tableDataProperties) + { + JsonNamingPolicy namingPolicy = JsonNamingPolicy.CamelCase; + yield return namingPolicy.ConvertName(tableDataProperties.UpdatedAtPropertyName); + yield return namingPolicy.ConvertName(tableDataProperties.VersionPropertyName); + yield return namingPolicy.ConvertName(tableDataProperties.DeletedPropertyName); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.Swashbuckle/SwaggerGenExtensions.cs b/src/CommunityToolkit.Datasync.Server.Swashbuckle/SwaggerGenExtensions.cs index 0a32c6e8..688adc64 100644 --- a/src/CommunityToolkit.Datasync.Server.Swashbuckle/SwaggerGenExtensions.cs +++ b/src/CommunityToolkit.Datasync.Server.Swashbuckle/SwaggerGenExtensions.cs @@ -2,6 +2,7 @@ // 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.Extensions.DependencyInjection; using Swashbuckle.AspNetCore.SwaggerGen; using System.Reflection; @@ -22,4 +23,14 @@ public static void AddDatasyncControllers(this SwaggerGenOptions options) { options.DocumentFilter(Assembly.GetCallingAssembly()); } -} + + /// + /// Adds the Datasync to the SwaggerGen pipeline. + /// + /// The representing the Swashbuckle configuration pipeline. + /// The CLR property map used for Datasync system metadata. + public static void AddDatasyncControllers(this SwaggerGenOptions options, TableDataPropertyMap tableDataProperties) + { + options.DocumentFilter(Assembly.GetCallingAssembly(), tableDataProperties); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Create.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Create.cs index 2ad5d012..9197e433 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Create.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Create.cs @@ -24,7 +24,8 @@ public virtual async Task CreateAsync(CancellationToken cancellat { Logger.LogInformation("CreateAsync"); TEntity entity = await DeserializeJsonContent(cancellationToken).ConfigureAwait(false); - Logger.LogInformation("CreateAsync: {id}", entity.Id); + TableDataAccessor tableData = GetTableDataAccessor(); + Logger.LogInformation("CreateAsync: {id}", tableData.GetId(entity)); if (Options.UnsafeEntityLogging) { Logger.LogDebug("CreateAsync: entity {entity}", entity.ToJsonString()); @@ -44,7 +45,7 @@ public virtual async Task CreateAsync(CancellationToken cancellat // In that case, return a generic Bad Request without the payload instead. if (!AccessControlProvider.EntityIsInView(conflictingEntity)) { - Logger.LogWarning("CreateAsync: {id} statusCode=400 conflicting entity not in view", entity.Id); + Logger.LogWarning("CreateAsync: {id} statusCode=400 conflicting entity not in view", tableData.GetId(entity)); throw new HttpException(StatusCodes.Status400BadRequest); } @@ -53,12 +54,12 @@ public virtual async Task CreateAsync(CancellationToken cancellat await PostCommitHookAsync(TableOperation.Create, entity, cancellationToken).ConfigureAwait(false); - Logger.LogInformation("CreateAsync: created {id}", entity.Id); + Logger.LogInformation("CreateAsync: created {id}", tableData.GetId(entity)); if (Options.UnsafeEntityLogging) { Logger.LogDebug("CreateAsync: created entity {entity}", entity.ToJsonString()); } - return CreatedAtRoute(new { id = entity.Id }, entity); + return CreatedAtRoute(new { id = tableData.GetId(entity) }, entity); } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Delete.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Delete.cs index 0427eaa1..02db497c 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Delete.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Delete.cs @@ -23,6 +23,7 @@ public partial class TableController : ODataController where TEntity : public virtual async Task DeleteAsync([FromRoute] string id, CancellationToken cancellationToken = default) { Logger.LogInformation("DeleteAsync: {id}", id); + TableDataAccessor tableData = GetTableDataAccessor(); TEntity entity = await Repository.ReadAsync(id, cancellationToken).ConfigureAwait(false); if (!AccessControlProvider.EntityIsInView(entity)) @@ -33,18 +34,18 @@ public virtual async Task DeleteAsync([FromRoute] string id, Canc await AuthorizeRequestAsync(TableOperation.Delete, entity, cancellationToken).ConfigureAwait(false); - if (Options.EnableSoftDelete && entity.Deleted) + if (Options.EnableSoftDelete && tableData.GetDeleted(entity)) { Logger.LogWarning("DeleteAsync: {id} statusCode=410 already deleted", id); throw new HttpException(StatusCodes.Status410Gone); } - Request.ParseConditionalRequest(entity, out byte[] version); + Request.ParseConditionalRequest(entity, tableData, out byte[] version); if (Options.EnableSoftDelete) { Logger.LogInformation("DeleteAsync: deleted {id} (soft-delete)", id); - entity.Deleted = true; + tableData.SetDeleted(entity, true); await AccessControlProvider.PreCommitHookAsync(TableOperation.Update, entity, cancellationToken).ConfigureAwait(false); await Repository.ReplaceAsync(entity, version, cancellationToken).ConfigureAwait(false); await PostCommitHookAsync(TableOperation.Update, entity, cancellationToken).ConfigureAwait(false); @@ -58,4 +59,4 @@ public virtual async Task DeleteAsync([FromRoute] string id, Canc return NoContent(); } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs index 5fc8dc1d..916a6168 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Query.cs @@ -58,6 +58,7 @@ public virtual async Task QueryAsync(CancellationToken cancellati ODataQuerySettings querySettings = new() { PageSize = Options.PageSize, EnsureStableOrdering = true }; ODataQueryContext queryContext = new(EdmModel, typeof(TEntity), new ODataPath()); ODataQueryOptions queryOptions = new(queryContext, Request); + TableDataAccessor tableData = GetTableDataAccessor(); try { @@ -72,7 +73,7 @@ public virtual async Task QueryAsync(CancellationToken cancellati // Determine the dataset to be queried for this user. IQueryable dataset = (await Repository.AsQueryableAsync(cancellationToken).ConfigureAwait(false)) .ApplyDataView(AccessControlProvider.GetDataView()) - .ApplyDeletedView(Request, Options.EnableSoftDelete); + .ApplyDeletedView(Request, Options.EnableSoftDelete, tableData); // Apply the requested filter from the OData transaction. IQueryable filteredDataset = dataset.ApplyODataFilter(queryOptions.Filter, querySettings); @@ -90,7 +91,7 @@ public virtual async Task QueryAsync(CancellationToken cancellati // Now apply the OrderBy, Skip, and Top options to the dataset. IQueryable orderedDataset = filteredDataset - .ApplyODataOrderBy(queryOptions.OrderBy, querySettings) + .ApplyODataOrderBy(queryOptions.OrderBy, querySettings, tableData) .ApplyODataPaging(queryOptions, querySettings); // Get the list of items within the dataset that need to be returned. @@ -183,4 +184,4 @@ static string CreateNextLink(HttpRequest request, int skip = 0, int top = 0) return result; } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Read.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Read.cs index 83086f21..7896713c 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Read.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Read.cs @@ -24,6 +24,7 @@ public partial class TableController : ODataController where TEntity : public virtual async Task ReadAsync([FromRoute] string id, CancellationToken cancellationToken = default) { Logger.LogInformation("ReadAsync: {id}", id); + TableDataAccessor tableData = GetTableDataAccessor(); TEntity entity = await Repository.ReadAsync(id, cancellationToken).ConfigureAwait(false); if (!AccessControlProvider.EntityIsInView(entity)) @@ -34,15 +35,15 @@ public virtual async Task ReadAsync([FromRoute] string id, Cancel await AuthorizeRequestAsync(TableOperation.Read, entity, cancellationToken).ConfigureAwait(false); - if (Options.EnableSoftDelete && entity.Deleted && !Request.ShouldIncludeDeletedEntities()) + if (Options.EnableSoftDelete && tableData.GetDeleted(entity) && !Request.ShouldIncludeDeletedEntities()) { Logger.LogWarning("ReadAsync: {id} statusCode=410 deleted", id); throw new HttpException(StatusCodes.Status410Gone); } - Request.ParseConditionalRequest(entity, out _); + Request.ParseConditionalRequest(entity, tableData, out _); - Logger.LogInformation("ReadAsync: read {id}", entity.Id); + Logger.LogInformation("ReadAsync: read {id}", tableData.GetId(entity)); if (Options.UnsafeEntityLogging) { Logger.LogDebug("ReadAsync: read entity {entity}", entity.ToJsonString()); @@ -50,4 +51,4 @@ public virtual async Task ReadAsync([FromRoute] string id, Cancel return Ok(entity); } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Replace.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Replace.cs index 2582f8c5..44dc3a42 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Replace.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.Replace.cs @@ -25,13 +25,14 @@ public virtual async Task ReplaceAsync([FromRoute] string id, Can { Logger.LogInformation("ReplaceAsync"); TEntity entity = await DeserializeJsonContent(cancellationToken).ConfigureAwait(false); + TableDataAccessor tableData = GetTableDataAccessor(); Logger.LogInformation("ReplaceAsync: {id}", id); if (Options.UnsafeEntityLogging) { Logger.LogDebug("ReplaceAsync: {id} entity {entity}", id, entity.ToJsonString()); } - if (id != entity.Id) + if (id != tableData.GetId(entity)) { Logger.LogWarning("ReplaceAsync: {id} statusCode=400 id mismatch", id); throw new HttpException(StatusCodes.Status400BadRequest); @@ -46,13 +47,13 @@ public virtual async Task ReplaceAsync([FromRoute] string id, Can } await AuthorizeRequestAsync(TableOperation.Update, existing, cancellationToken).ConfigureAwait(false); - if (Options.EnableSoftDelete && existing.Deleted && !Request.ShouldIncludeDeletedEntities()) + if (Options.EnableSoftDelete && tableData.GetDeleted(existing) && !Request.ShouldIncludeDeletedEntities()) { Logger.LogWarning("ReplaceAsync: {id} statusCode=410 deleted", id); throw new HttpException(StatusCodes.Status410Gone); } - Request.ParseConditionalRequest(existing, out byte[] version); + Request.ParseConditionalRequest(existing, tableData, out byte[] version); await AccessControlProvider.PreCommitHookAsync(TableOperation.Update, entity, cancellationToken).ConfigureAwait(false); await Repository.ReplaceAsync(entity, version, cancellationToken).ConfigureAwait(false); await PostCommitHookAsync(TableOperation.Update, entity, cancellationToken).ConfigureAwait(false); @@ -61,7 +62,7 @@ public virtual async Task ReplaceAsync([FromRoute] string id, Can // operation, so we have to do an additional GET to ensure we are getting the right version of the entity TEntity? updatedEntity = await Repository.ReadAsync(id, cancellationToken).ConfigureAwait(false); - Logger.LogInformation("ReplaceAsync: replaced {id}", updatedEntity?.Id); + Logger.LogInformation("ReplaceAsync: replaced {id}", updatedEntity is null ? null : tableData.GetId(updatedEntity)); if (Options.UnsafeEntityLogging) { Logger.LogDebug("ReplaceAsync: replaced entity {entity}", updatedEntity?.ToJsonString() ?? "null"); @@ -69,4 +70,4 @@ public virtual async Task ReplaceAsync([FromRoute] string id, Can return Ok(updatedEntity); } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.cs b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.cs index 3d59ca5b..56352834 100644 --- a/src/CommunityToolkit.Datasync.Server/Controllers/TableController.cs +++ b/src/CommunityToolkit.Datasync.Server/Controllers/TableController.cs @@ -160,7 +160,7 @@ protected virtual async ValueTask AuthorizeRequestAsync(TableOperation operation bool isAuthorized = await AccessControlProvider.IsAuthorizedAsync(operation, entity, cancellationToken).ConfigureAwait(false); if (!isAuthorized) { - Logger.LogWarning("{operation} {id} statusCode=401 unauthorized", operation, entity?.Id ?? ""); + Logger.LogWarning("{operation} {id} statusCode=401 unauthorized", operation, entity is null ? "" : GetTableDataAccessor().GetId(entity) ?? ""); if (Options.UnsafeEntityLogging) { Logger.LogDebug("{operation} entity {entity} statusCode=401 unauthorized", operation, entity?.ToJsonString() ?? ""); @@ -194,7 +194,7 @@ protected virtual ValueTask PostCommitHookAsync(TableOperation operation, TEntit [NonAction] protected async ValueTask DeserializeJsonContent(CancellationToken cancellationToken = default) { - IDatasyncServiceOptions options = HttpContext.RequestServices?.GetService() ?? new DatasyncServiceOptions(); + IDatasyncServiceOptions options = GetDatasyncServiceOptions(); HttpContext.Request.EnableBuffering(); if (HttpContext.Request.HasJsonContentType()) { @@ -214,4 +214,20 @@ protected async ValueTask DeserializeJsonContent(CancellationToken canc throw new HttpException(StatusCodes.Status415UnsupportedMediaType, "Unsupported media type"); } } -} + + /// + /// Retrieves the Datasync service options for the current request. + /// + /// The Datasync service options. + [NonAction] + protected IDatasyncServiceOptions GetDatasyncServiceOptions() + => HttpContext?.RequestServices?.GetService() ?? new DatasyncServiceOptions(); + + /// + /// Retrieves the Datasync metadata accessor for the current entity type. + /// + /// The Datasync metadata accessor. + [NonAction] + protected TableDataAccessor GetTableDataAccessor() + => Options.GetTableDataProperties(GetDatasyncServiceOptions()).GetAccessor(); +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Extensions/InternalExtensions.cs b/src/CommunityToolkit.Datasync.Server/Extensions/InternalExtensions.cs index aa94efd1..f36b1406 100644 --- a/src/CommunityToolkit.Datasync.Server/Extensions/InternalExtensions.cs +++ b/src/CommunityToolkit.Datasync.Server/Extensions/InternalExtensions.cs @@ -42,9 +42,10 @@ internal static IQueryable ApplyDataView(this IQueryable query, Express /// The current representing the query. /// The current being processed. /// A flag to indicate if soft-delete is enabled on the table being queried. + /// /// An updated representing the new query. - internal static IQueryable ApplyDeletedView(this IQueryable query, HttpRequest request, bool enableSoftDelete) where T : ITableData - => !enableSoftDelete || request.ShouldIncludeDeletedEntities() ? query : query.Where(e => !e.Deleted); + internal static IQueryable ApplyDeletedView(this IQueryable query, HttpRequest request, bool enableSoftDelete, TableDataAccessor tableData) where T : class + => !enableSoftDelete || request.ShouldIncludeDeletedEntities() ? query : query.Where(tableData.CreateNotDeletedExpression()); /// /// Applies the $filter OData query option to the provided query. @@ -64,9 +65,10 @@ internal static IQueryable ApplyODataFilter(this IQueryable query, Filt /// The current representing the query. /// The ordering query option to apply. /// The query settings being used. + /// /// A modified representing the ordered data. - internal static IQueryable ApplyODataOrderBy(this IQueryable query, OrderByQueryOption? orderingQueryOption, ODataQuerySettings settings) where T : ITableData - => orderingQueryOption?.ApplyTo(query, settings).ThenBy(e => e.Id) ?? query.OrderBy(e => e.Id); + internal static IQueryable ApplyODataOrderBy(this IQueryable query, OrderByQueryOption? orderingQueryOption, ODataQuerySettings settings, TableDataAccessor tableData) where T : class + => orderingQueryOption?.ApplyTo(query, settings).ThenBy(tableData.IdExpression) ?? query.OrderBy(tableData.IdExpression); /// /// Applies the $skip and $top OData query options to the provided query. @@ -162,29 +164,32 @@ internal static bool Matches(this EntityTagHeaderValue etag, byte[] version) /// The type of entity being checked. /// The current object that contains the request headers. /// The entity being checked. + /// /// On conclusion, the version that was requested. /// Thrown if the conditional request requirements are not met. - internal static void ParseConditionalRequest(this HttpRequest request, TEntity entity, out byte[] version) where TEntity : ITableData + internal static void ParseConditionalRequest(this HttpRequest request, TEntity entity, TableDataAccessor tableData, out byte[] version) where TEntity : class { RequestHeaders headers = request.GetTypedHeaders(); bool isFetch = request.Method.Equals("GET", StringComparison.InvariantCultureIgnoreCase); + byte[] entityVersion = tableData.GetVersion(entity); + DateTimeOffset? updatedAt = tableData.GetUpdatedAt(entity); - if (headers.IfMatch.Count > 0 && !headers.IfMatch.Any(e => e.Matches(entity.Version))) + if (headers.IfMatch.Count > 0 && !headers.IfMatch.Any(e => e.Matches(entityVersion))) { throw new HttpException(StatusCodes.Status412PreconditionFailed) { Payload = entity }; } - if (headers.IfMatch.Count == 0 && headers.IfUnmodifiedSince.HasValue && headers.IfUnmodifiedSince.Value.IsBefore(entity.UpdatedAt)) + if (headers.IfMatch.Count == 0 && headers.IfUnmodifiedSince.HasValue && headers.IfUnmodifiedSince.Value.IsBefore(updatedAt)) { throw new HttpException(StatusCodes.Status412PreconditionFailed) { Payload = entity }; } - if (headers.IfNoneMatch.Count > 0 && headers.IfNoneMatch.Any(e => e.Matches(entity.Version))) + if (headers.IfNoneMatch.Count > 0 && headers.IfNoneMatch.Any(e => e.Matches(entityVersion))) { throw isFetch ? new HttpException(StatusCodes.Status304NotModified) : new HttpException(StatusCodes.Status412PreconditionFailed) { Payload = entity }; } - if (headers.IfNoneMatch.Count == 0 && headers.IfModifiedSince.HasValue && headers.IfModifiedSince.Value.IsAfter(entity.UpdatedAt)) + if (headers.IfNoneMatch.Count == 0 && headers.IfModifiedSince.HasValue && headers.IfModifiedSince.Value.IsAfter(updatedAt)) { throw isFetch ? new HttpException(StatusCodes.Status304NotModified) : new HttpException(StatusCodes.Status412PreconditionFailed) { Payload = entity }; } @@ -192,27 +197,49 @@ internal static void ParseConditionalRequest(this HttpRequest request, version = headers.IfMatch.SingleOrDefault()?.ToByteArray() ?? []; } + /// + /// Determines if the request has met the preconditions within the conditional headers, according to RFC 7232 section 5 and 6. + /// + /// The type of entity being checked. + /// The current object that contains the request headers. + /// The entity being checked. + /// On conclusion, the version that was requested. + internal static void ParseConditionalRequest(this HttpRequest request, TEntity entity, out byte[] version) where TEntity : class, ITableData + => request.ParseConditionalRequest(entity, new TableDataPropertyMap().GetAccessor(), out version); + /// /// Adds the required conditional headers to a header dictionary. /// /// The current header dictionary. /// Tne entity to use for setting conditional header values. - internal static void SetConditionalHeaders(this IHeaderDictionary headers, ITableData entity) + /// + internal static void SetConditionalHeaders(this IHeaderDictionary headers, object entity, TableDataPropertyMap tableDataProperties) { _ = headers.Remove(HeaderNames.ETag); _ = headers.Remove(HeaderNames.LastModified); + TableDataAccessor tableData = tableDataProperties.GetAccessor(entity.GetType()); + byte[] version = tableData.GetVersion(entity); + DateTimeOffset? updatedAt = tableData.GetUpdatedAt(entity); - if (entity.Version.Length > 0) + if (version.Length > 0) { - headers.Append(HeaderNames.ETag, $"\"{entity.Version.ToEntityTagValue()}\""); + headers.Append(HeaderNames.ETag, $"\"{version.ToEntityTagValue()}\""); } - if (entity.UpdatedAt.HasValue && entity.UpdatedAt.Value != default) + if (updatedAt.HasValue && updatedAt.Value != default) { - headers.Append(HeaderNames.LastModified, entity.UpdatedAt.Value.ToString(DateTimeFormatInfo.InvariantInfo.RFC1123Pattern, CultureInfo.InvariantCulture)); + headers.Append(HeaderNames.LastModified, updatedAt.Value.ToString(DateTimeFormatInfo.InvariantInfo.RFC1123Pattern, CultureInfo.InvariantCulture)); } } + /// + /// Adds the required conditional headers to a header dictionary. + /// + /// The current header dictionary. + /// Tne entity to use for setting conditional header values. + internal static void SetConditionalHeaders(this IHeaderDictionary headers, ITableData entity) + => headers.SetConditionalHeaders(entity, new TableDataPropertyMap()); + /// /// Determines if the client requested that the deleted items should be considered to /// be "in view". @@ -270,4 +297,4 @@ internal static string ToJsonString(this object @object, JsonSerializerOptions? return "unserializable object"; } } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Extensions/PublicExtensions.cs b/src/CommunityToolkit.Datasync.Server/Extensions/PublicExtensions.cs index 2123515c..01e14361 100644 --- a/src/CommunityToolkit.Datasync.Server/Extensions/PublicExtensions.cs +++ b/src/CommunityToolkit.Datasync.Server/Extensions/PublicExtensions.cs @@ -20,8 +20,35 @@ public static class PublicExtensions /// The (optional) to use for configuring OData. /// The modified service collection. public static IServiceCollection AddDatasyncServices(this IServiceCollection services, IEdmModel? model = null) + => services.AddDatasyncServices(model, null); + + /// + /// Adds the services necessary to provide OData functionality for the Datasync service, building + /// the from the entities configured via . + /// + /// The current service collection. + /// A callback used to configure Datasync service options. + /// The modified service collection. + public static IServiceCollection AddDatasyncServices(this IServiceCollection services, Action configure) + => services.AddDatasyncServices(null, configure); + + /// + /// Adds the services necessary to provide OData functionality for the Datasync service, building + /// the from the entities configured via . + /// + /// The current service collection. + /// The (optional) to use for configuring OData. + /// A callback used to configure Datasync service options. + /// The modified service collection. + public static IServiceCollection AddDatasyncServices(this IServiceCollection services, IEdmModel? model, Action? configure) { - _ = services.AddSingleton(); + _ = services.AddSingleton(_ => + { + DatasyncServiceOptions options = new(); + configure?.Invoke(options); + return options; + }); + if (model is not null) { _ = services.AddSingleton(model); @@ -29,4 +56,4 @@ public static IServiceCollection AddDatasyncServices(this IServiceCollection ser return services; } -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Filters/DatasyncControllerAttribute.cs b/src/CommunityToolkit.Datasync.Server/Filters/DatasyncControllerAttribute.cs index c2e4db10..b82067ba 100644 --- a/src/CommunityToolkit.Datasync.Server/Filters/DatasyncControllerAttribute.cs +++ b/src/CommunityToolkit.Datasync.Server/Filters/DatasyncControllerAttribute.cs @@ -22,16 +22,17 @@ public override void OnResultExecuting(ResultExecutingContext context) { if (context.Result is ObjectResult result) { + IDatasyncServiceOptions options = GetDatasyncServiceOptions(context.HttpContext); if (result.Value is ITableData entity) { - context.HttpContext.Response.Headers.SetConditionalHeaders(entity); + context.HttpContext.Response.Headers.SetConditionalHeaders(entity, options.TableDataProperties); if (result.StatusCode == StatusCodes.Status201Created) { - context.HttpContext.Response.Headers.Location = $"{context.HttpContext.Request.GetDisplayUrl()}/{entity.Id}"; + string? id = options.TableDataProperties.GetAccessor(entity.GetType()).GetId(entity); + context.HttpContext.Response.Headers.Location = $"{context.HttpContext.Request.GetDisplayUrl()}/{id}"; } } - IDatasyncServiceOptions options = GetDatasyncServiceOptions(context.HttpContext); context.Result = new JsonResult(result.Value, options.JsonSerializerOptions) { StatusCode = result.StatusCode }; } @@ -50,7 +51,7 @@ public void OnException(ExceptionContext context) if (exception.Payload is ITableData entity) { - context.HttpContext.Response.Headers.SetConditionalHeaders(entity); + context.HttpContext.Response.Headers.SetConditionalHeaders(entity, options.TableDataProperties); } context.ExceptionHandled = true; @@ -64,4 +65,4 @@ public void OnException(ExceptionContext context) /// private static IDatasyncServiceOptions GetDatasyncServiceOptions(HttpContext context) => context.RequestServices?.GetService() ?? new DatasyncServiceOptions(); -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs b/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs index 7cb1a29c..7789c95d 100644 --- a/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs +++ b/src/CommunityToolkit.Datasync.Server/Models/DatasyncServiceOptions.cs @@ -28,6 +28,9 @@ public DatasyncServiceOptions() /// public JsonSerializerOptions JsonSerializerOptions => this._options.Value; + /// + public TableDataPropertyMap TableDataProperties { get; } = new(); + private static JsonSerializerOptions GetJsonSerializerOptions() => new(JsonSerializerDefaults.Web) { AllowTrailingCommas = true, @@ -49,4 +52,4 @@ public DatasyncServiceOptions() PropertyNamingPolicy = JsonNamingPolicy.CamelCase, ReadCommentHandling = JsonCommentHandling.Skip }; -} +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Models/IDatasyncServiceOptions.cs b/src/CommunityToolkit.Datasync.Server/Models/IDatasyncServiceOptions.cs index 4c8bf721..c5a0356a 100644 --- a/src/CommunityToolkit.Datasync.Server/Models/IDatasyncServiceOptions.cs +++ b/src/CommunityToolkit.Datasync.Server/Models/IDatasyncServiceOptions.cs @@ -18,4 +18,9 @@ public interface IDatasyncServiceOptions /// The serializer options to use for communication with a Datasync client. /// JsonSerializerOptions JsonSerializerOptions { get; } -} + + /// + /// The CLR property map used for Datasync system metadata. + /// + TableDataPropertyMap TableDataProperties { get; } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server/Models/TableControllerOptions.cs b/src/CommunityToolkit.Datasync.Server/Models/TableControllerOptions.cs index ae08da6c..2afc7f73 100644 --- a/src/CommunityToolkit.Datasync.Server/Models/TableControllerOptions.cs +++ b/src/CommunityToolkit.Datasync.Server/Models/TableControllerOptions.cs @@ -55,6 +55,12 @@ public class TableControllerOptions /// public bool UnsafeEntityLogging { get; set; } + /// + /// The CLR property map used for Datasync system metadata by this controller. + /// If unset, the globally configured Datasync service options are used. + /// + public TableDataPropertyMap? TableDataProperties { get; set; } + /// /// The maximum page size for the results returned by a query operation. This is the /// maximum value that the client can specify for the $top query option. @@ -102,4 +108,7 @@ public int UnauthorizedStatusCode this._unauthorizedStatusCode = value; } } -} + + internal TableDataPropertyMap GetTableDataProperties(IDatasyncServiceOptions? serviceOptions = null) + => TableDataProperties ?? serviceOptions?.TableDataProperties ?? new TableDataPropertyMap(); +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Extensions/InternalExtensions_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Extensions/InternalExtensions_Tests.cs index 7b260cbd..5391331c 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Extensions/InternalExtensions_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Extensions/InternalExtensions_Tests.cs @@ -27,6 +27,9 @@ public class InternalExtensions_Tests Version = [0x01, 0x00, 0x42, 0x22, 0x47, 0x8F] }; + private readonly TableDataPropertyMap mappedProperties = new TableDataPropertyMap() + .Map(id: "Key", updatedAt: "ChangedOn", version: "Token", deleted: "Removed"); + private const string matchingETag = "\"AQBCIkeP\""; private const string nonMatchingETag = "\"Foo\""; @@ -37,6 +40,28 @@ class TestObject { public TestObject Arg { get; set; } } + + class MappedTableData : ITableData + { + public string Id { get; set; } = string.Empty; + + public bool Deleted { get; set; } + + public DateTimeOffset? UpdatedAt { get; set; } + + public byte[] Version { get; set; } = []; + + public string Key { get; set; } = string.Empty; + + public bool Removed { get; set; } + + public DateTimeOffset? ChangedOn { get; set; } + + public byte[] Token { get; set; } = []; + + public bool Equals(ITableData other) + => other is not null && Id == other.Id && Version.SequenceEqual(other.Version); + } #endregion #region EntityIsInView(IAccessControlProvider, TEntity) @@ -111,6 +136,24 @@ public void EntityTagHeaderValue_Matches_Working(bool isWeak, string tagValue, b } #endregion + #region ApplyDeletedView(IQueryable, HttpRequest, bool, TableDataAccessor) + [Fact] + public void ApplyDeletedView_WithMappedDeletedProperty_Works() + { + DefaultHttpContext context = new(); + TableDataAccessor tableData = this.mappedProperties.GetAccessor(); + IQueryable query = new[] + { + new MappedTableData { Key = "active", Removed = false }, + new MappedTableData { Key = "removed", Removed = true } + }.AsQueryable(); + + List results = query.ApplyDeletedView(context.Request, true, tableData).ToList(); + + results.Should().ContainSingle().Which.Key.Should().Be("active"); + } + #endregion + #region ParseConditionalRequest(HttpRequest, TEntity, out byte[]) [Theory] [InlineData("GET", null, null, false)] @@ -144,6 +187,24 @@ public void ParseConditionalRequest_Success(string method, string headerName, st } } + [Fact] + public void ParseConditionalRequest_WithMappedProperties_Works() + { + HttpContext context = new DefaultHttpContext(); + context.Request.Method = "POST"; + context.Request.Headers["If-Match"] = matchingETag; + MappedTableData entity = new() + { + Key = "mapped", + ChangedOn = DateTimeOffset.Parse("2023-11-13T12:53:13.123Z"), + Token = [0x01, 0x00, 0x42, 0x22, 0x47, 0x8F] + }; + + context.Request.ParseConditionalRequest(entity, this.mappedProperties.GetAccessor(), out byte[] version); + + version.Should().BeEquivalentTo(entity.Token); + } + [Theory] [InlineData("Get", "If-Match", nonMatchingETag, 412)] [InlineData("get", "If-None-Match", matchingETag, 304)] @@ -244,6 +305,22 @@ public void AddHeadersFromEntity_AddLastModifiedHeader(bool includeHeader) headers.Should().ContainKey("Last-Modified").WhoseValue.Should().ContainSingle(v => v == "Mon, 13 Nov 2023 13:30:05 GMT"); } + [Fact] + public void AddHeadersFromEntity_WithMappedProperties_Works() + { + HeaderDictionary headers = []; + MappedTableData entity = new() + { + Token = [0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68], + ChangedOn = DateTimeOffset.Parse("2023-11-13T13:30:05.1234Z") + }; + + headers.SetConditionalHeaders(entity, this.mappedProperties); + + headers.Should().ContainKey("ETag").WhoseValue.Should().ContainSingle(v => v == "\"YWJjZGVmZ2g=\""); + headers.Should().ContainKey("Last-Modified").WhoseValue.Should().ContainSingle(v => v == "Mon, 13 Nov 2023 13:30:05 GMT"); + } + #endregion #region ShouldIncludeDeletedEntities(HttpRequest) @@ -318,4 +395,4 @@ public void ToJsonString_Catches_UnserializableObjects() actual.Should().Be("unserializable object"); } #endregion -} +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Datasync.Server.Test/Models/DatasyncServiceOptions_Tests.cs b/tests/CommunityToolkit.Datasync.Server.Test/Models/DatasyncServiceOptions_Tests.cs index 655aff64..36cddcc6 100644 --- a/tests/CommunityToolkit.Datasync.Server.Test/Models/DatasyncServiceOptions_Tests.cs +++ b/tests/CommunityToolkit.Datasync.Server.Test/Models/DatasyncServiceOptions_Tests.cs @@ -17,4 +17,28 @@ public void JsonSerializerOptions_Works() options.Converters.Should().NotBeNullOrEmpty(); options.PropertyNamingPolicy.Should().Be(JsonNamingPolicy.CamelCase); } -} + + [Fact] + public void TableDataProperties_Defaults_Work() + { + DatasyncServiceOptions options = new(); + + options.TableDataProperties.IdPropertyName.Should().Be("Id"); + options.TableDataProperties.UpdatedAtPropertyName.Should().Be("UpdatedAt"); + options.TableDataProperties.VersionPropertyName.Should().Be("Version"); + options.TableDataProperties.DeletedPropertyName.Should().Be("Deleted"); + } + + [Fact] + public void TableDataProperties_CustomMap_Works() + { + DatasyncServiceOptions options = new(); + + options.TableDataProperties.Map(id: "Key", updatedAt: "ChangedOn", version: "Token", deleted: "Removed"); + + options.TableDataProperties.IdPropertyName.Should().Be("Key"); + options.TableDataProperties.UpdatedAtPropertyName.Should().Be("ChangedOn"); + options.TableDataProperties.VersionPropertyName.Should().Be("Token"); + options.TableDataProperties.DeletedPropertyName.Should().Be("Removed"); + } +} \ No newline at end of file