From 887a153bf13885677e6e38b83aaa8b14af5ae05a Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Sun, 7 Nov 2021 00:12:27 +0100 Subject: [PATCH 01/34] Add NoSqlResourceService This commit adds the NoSqlResourceService and related classes and interfaces: - NoSqlServiceCollectionExtensions: Used in Startup to add injectables - INoSqlQueryLayerComposer: Defines the NoSQL QueryLayer composer contract - NoSqlQueryLayerComposer: Implements the NoSQL QueryLayer composer - NoSqlHasForeignKeyAttribute: Adds foreign key information to navigation properties - NoSqlOwnsManyAttribute: Identifies relationships as owned - NoSqlResourceAttribute: Identifies NoSQL resources (as opposed to owned entities) --- .../NoSqlServiceCollectionExtensions.cs | 76 ++ .../Queries/INoSqlQueryLayerComposer.cs | 69 ++ .../Queries/NoSqlQueryLayerComposer.cs | 199 ++++++ .../NoSqlHasForeignKeyAttribute.cs | 51 ++ .../Annotations/NoSqlOwnsManyAttribute.cs | 36 + .../Annotations/NoSqlResourceAttribute.cs | 19 + .../Services/NoSqlResourceService.cs | 671 ++++++++++++++++++ 7 files changed, 1121 insertions(+) create mode 100644 src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs create mode 100644 src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs create mode 100644 src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/NoSqlHasForeignKeyAttribute.cs create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs create mode 100644 src/JsonApiDotNetCore/Resources/Annotations/NoSqlResourceAttribute.cs create mode 100644 src/JsonApiDotNetCore/Services/NoSqlResourceService.cs diff --git a/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs new file mode 100644 index 0000000000..f8aef34442 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs @@ -0,0 +1,76 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.Configuration +{ + /// + /// Provides extension methods for the registration of services and other injectables + /// with the service container. + /// + [PublicAPI] + public static class NoSqlServiceCollectionExtensions + { + /// + /// For each resource annotated with the , adds a + /// scoped service with a service type of + /// and an implementation type of . + /// + /// The . + /// The . + public static IServiceCollection AddNoSqlResourceServices(this IServiceCollection services) + { + return services.AddNoSqlResourceServices(Assembly.GetCallingAssembly()); + } + + /// + /// For each resource annotated with the , adds a + /// scoped service with a service type of + /// and an implementation type of . + /// + /// The . + /// The containing the annotated resources. + /// The . + public static IServiceCollection AddNoSqlResourceServices(this IServiceCollection services, Assembly assembly) + { + services.AddScoped(); + + foreach (Type resourceType in assembly.ExportedTypes.Where(IsNoSqlResource)) + { + if (TryGetIdType(resourceType, out Type? idType)) + { + services.AddScoped( + typeof(IResourceService<,>).MakeGenericType(resourceType, idType), + typeof(NoSqlResourceService<,>).MakeGenericType(resourceType, idType)); + } + } + + return services; + } + + private static bool IsNoSqlResource(Type type) + { + return Attribute.GetCustomAttribute(type, typeof(NoSqlResourceAttribute)) is not null && + type.GetInterfaces().Any(IsGenericIIdentifiable); + } + + private static bool IsGenericIIdentifiable(Type type) + { + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IIdentifiable<>); + } + + private static bool TryGetIdType(Type resourceType, [NotNullWhen(true)] out Type? idType) + { + Type? identifiableInterface = resourceType.GetInterfaces().FirstOrDefault(IsGenericIIdentifiable); + idType = identifiableInterface?.GetGenericArguments()[0]; + return idType is not null; + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs new file mode 100644 index 0000000000..5b2437684b --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs @@ -0,0 +1,69 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries +{ + /// + /// Takes scoped expressions from s and transforms them. + /// Additionally provides specific transformations for NoSQL databases without support for joins. + /// + [PublicAPI] + public interface INoSqlQueryLayerComposer + { + /// + /// Builds a filter from constraints, used to determine total resource count on a primary + /// collection endpoint. + /// + FilterExpression? GetPrimaryFilterFromConstraintsForNoSql(ResourceType primaryResourceType); + + /// + /// Composes a and an from the + /// constraints specified by the request. Used for primary resources. + /// + (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType); + + /// + /// Composes a and an from the + /// constraints specified by the request. Used for primary resources. + /// + (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql( + TId id, + ResourceType primaryResourceType, + TopFieldSelection fieldSelection) + where TId : notnull; + + + /// + /// Composes a with a filter expression in the form "equals(id,'{stringId}')". + /// + QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceType) + where TId : notnull; + + /// + /// Composes a from the constraints specified by the request + /// and a filter expression in the form "equals({propertyName},'{propertyValue}')". + /// Used for secondary or included resources. + /// + /// The of the secondary or included resource. + /// The name of the property of the secondary or included resource used for filtering. + /// The value of the property of the secondary or included resource used for filtering. + /// + /// , if the resource is included by the request (e.g., "{url}?include={relationshipName}"); + /// , if the resource is a secondary resource (e.g., "/{primary}/{id}/{relationshipName}"). + /// + /// A tuple with a and an . + (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsAndFilterForNoSql( + ResourceType requestResourceType, + string propertyName, + string propertyValue, + bool isIncluded); + + /// + /// Builds a query that retrieves the primary resource, including all of its attributes + /// and all targeted relationships, during a create/update/delete request. + /// + (QueryLayer QueryLayer, IncludeExpression Include) ComposeForUpdateForNoSql(TId id, ResourceType primaryResourceType) + where TId : notnull; + } +} diff --git a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs new file mode 100644 index 0000000000..b2b80136a9 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs @@ -0,0 +1,199 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries +{ + /// + /// Default implementation of the . + /// + /// + /// Register with the service container as + /// shown in the following example. + /// + /// + /// + /// + [PublicAPI] + public class NoSqlQueryLayerComposer : QueryLayerComposer, INoSqlQueryLayerComposer + { + private readonly IEnumerable _constraintProviders; + private readonly ITargetedFields _targetedFields; + + public NoSqlQueryLayerComposer( + IEnumerable constraintProviders, + IResourceDefinitionAccessor resourceDefinitionAccessor, + IJsonApiOptions options, + IPaginationContext paginationContext, + ITargetedFields targetedFields, + IEvaluatedIncludeCache evaluatedIncludeCache, + ISparseFieldSetCache sparseFieldSetCache) : base(constraintProviders, resourceDefinitionAccessor, options, paginationContext, targetedFields, evaluatedIncludeCache, sparseFieldSetCache) + { + _constraintProviders = constraintProviders; + _targetedFields = targetedFields; + } + + /// + public FilterExpression? GetPrimaryFilterFromConstraintsForNoSql(ResourceType primaryResourceType) + { + return GetPrimaryFilterFromConstraints(primaryResourceType); + } + + /// + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType) + { + QueryLayer queryLayer = ComposeFromConstraints(requestResourceType); + IncludeExpression include = queryLayer.Include ?? IncludeExpression.Empty; + + queryLayer.Include = IncludeExpression.Empty; + queryLayer.Projection = null; + + return (queryLayer, include); + } + + /// + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql( + TId id, + ResourceType primaryResourceType, + TopFieldSelection fieldSelection) + where TId : notnull + { + QueryLayer queryLayer = ComposeForGetById(id, primaryResourceType, fieldSelection); + IncludeExpression include = queryLayer.Include ?? IncludeExpression.Empty; + + queryLayer.Include = IncludeExpression.Empty; + queryLayer.Projection = null; + + return (queryLayer, include); + } + + /// + public QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceType) + where TId : notnull + { + return new QueryLayer(primaryResourceType) + { + Filter = new ComparisonExpression( + ComparisonOperator.Equals, + new ResourceFieldChainExpression( + primaryResourceType.Fields.Single(f => f.Property.Name == nameof(IIdentifiable.Id))), + new LiteralConstantExpression(id.ToString()!)), + Include = IncludeExpression.Empty, + }; + } + + /// + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsAndFilterForNoSql( + ResourceType requestResourceType, + string propertyName, + string propertyValue, + bool isIncluded) + { + // Compose a secondary resource filter in the form "equals({propertyName},'{propertyValue}')". + FilterExpression[] secondaryResourceFilterExpressions = + { + ComposeSecondaryResourceFilter(requestResourceType, propertyName, propertyValue), + }; + + // Get the query expressions from the request. + ExpressionInScope[] constraints = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .ToArray(); + + bool IsQueryLayerConstraint(ExpressionInScope constraint) + { + return constraint.Expression is not IncludeExpression && + (!isIncluded || (constraint.Scope is not null && + constraint.Scope.Fields.Any(field => field.PublicName == requestResourceType.PublicName))); + } + + IEnumerable requestQueryExpressions = constraints + .Where(IsQueryLayerConstraint) + .Select(constraint => constraint.Expression); + + // Combine the secondary resource filter and request query expressions and + // create the query layer from the combined query expressions. + QueryExpression[] queryExpressions = secondaryResourceFilterExpressions + .Concat(requestQueryExpressions) + .ToArray(); + + var queryLayer = new QueryLayer(requestResourceType) + { + Include = IncludeExpression.Empty, + Filter = GetFilter(queryExpressions, requestResourceType), + Sort = GetSort(queryExpressions, requestResourceType), + Pagination = GetPagination(queryExpressions, requestResourceType), + }; + + // Retrieve the IncludeExpression from the constraints collection. + // There will be zero or one IncludeExpression, even if multiple include query + // parameters were specified in the request. JsonApiDotNetCore combines those + // into a single expression. + IncludeExpression include = isIncluded + ? IncludeExpression.Empty + : constraints + .Select(constraint => constraint.Expression) + .OfType() + .DefaultIfEmpty(IncludeExpression.Empty) + .Single(); + + return (queryLayer, include); + } + + private static FilterExpression ComposeSecondaryResourceFilter( + ResourceType resourceType, + string propertyName, + string properyValue) + { + return new ComparisonExpression( + ComparisonOperator.Equals, + new ResourceFieldChainExpression(resourceType.Fields.Single(f => f.Property.Name == propertyName)), + new LiteralConstantExpression(properyValue)); + } + + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForUpdateForNoSql( + TId id, + ResourceType primaryResourceType) + where TId : notnull + { + // Create primary layer without an include expression. + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + QueryLayer primaryLayer = new(primaryResourceType) + { + Include = IncludeExpression.Empty, + Filter = new ComparisonExpression( + ComparisonOperator.Equals, + new ResourceFieldChainExpression(primaryIdAttribute), + new LiteralConstantExpression(id.ToString()!)), + }; + + // Create a separate include expression. + ImmutableHashSet includeElements = _targetedFields.Relationships + .Select(relationship => new IncludeElementExpression(relationship)) + .ToImmutableHashSet(); + + IncludeExpression include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty; + + return (primaryLayer, include); + } + + private static AttrAttribute GetIdAttribute(ResourceType resourceType) + { + return resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); + } + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlHasForeignKeyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlHasForeignKeyAttribute.cs new file mode 100644 index 0000000000..4ac1026598 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlHasForeignKeyAttribute.cs @@ -0,0 +1,51 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// Used to provide additional information for a JSON:API relationship with a foreign key + /// (https://jsonapi.org/format/#document-resource-object-relationships). + /// + /// + /// + /// { + /// [HasMany] + /// [NoSqlHasForeignKey(nameof(Article.AuthorId))] + /// public ICollection
Articles { get; set; } + /// } + /// + /// public class Article : Identifiable + /// { + /// public Guid AuthorId { get; set; } + /// + /// [HasOne] + /// [NoSqlHasForeignKey(nameof(AuthorId))] + /// public Author Author { get; set; } + /// } + /// ]]> + /// + [PublicAPI] + [AttributeUsage(AttributeTargets.Property)] + public class NoSqlHasForeignKeyAttribute : Attribute + { + public NoSqlHasForeignKeyAttribute(string propertyName) + { + PropertyName = propertyName; + } + + /// + /// Gets the name of the foreign key property corresponding to the annotated + /// navigation property. + /// + public string PropertyName { get; private set; } + + /// + /// Gets or sets a value indicating whether the navigation property is on + /// the dependent side of the foreign key relationship. The default is + /// . + /// + public bool IsDependent { get; set; } = true; + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs new file mode 100644 index 0000000000..43dccc784f --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs @@ -0,0 +1,36 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// Used to provide additional information for a JSON:API relationship + /// (https://jsonapi.org/format/#document-resource-object-relationships). + /// + /// + /// With Cosmos DB, for example, an entity can own one or many other entity types. + /// In the example below, an Author entity owns many Article entities, which is also + /// reflected in the Entity Framework Core model. This means that all owned Article + /// entities will be returned with the Author. To represent Article entities as a + /// to-many relationship, the [HasMany] and [NoSqlOwnsMany] annotations can be combined + /// to indicate to the NoSQL resource service that those Article resources do not have + /// to be fetched. To include the Article resources into the response, the request will + /// have to add an include expression such as "?include=articles". Otherwise, the + /// Article resources will not be returned. + /// + /// + /// + /// { + /// [HasMany] + /// [NoSqlOwnsMany] + /// public ICollection
Articles { get; set; } + /// } + /// ]]> + /// + [PublicAPI] + [AttributeUsage(AttributeTargets.Property)] + public class NoSqlOwnsManyAttribute : Attribute + { + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlResourceAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlResourceAttribute.cs new file mode 100644 index 0000000000..7d2ef4d520 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlResourceAttribute.cs @@ -0,0 +1,19 @@ +using System; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Resources.Annotations +{ + /// + /// When put on a resource class, marks that resource as being hosted in a NoSQL database. + /// The will + /// register a for each resource + /// having this attribute. + /// + [PublicAPI] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] + public class NoSqlResourceAttribute : Attribute + { + } +} diff --git a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs new file mode 100644 index 0000000000..1a1be6f828 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs @@ -0,0 +1,671 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.Logging; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace JsonApiDotNetCore.Services +{ + /// + /// Provides the resource services where a NoSQL database such as Cosmos DB or MongoDB + /// is used as a back-end database. + /// + /// + /// Register with the service container + /// as shown in the example. + /// + /// + /// + /// + /// The type of the resource. + /// The type of the resource Id. + [PublicAPI] + public class NoSqlResourceService : IResourceService + where TResource : class, IIdentifiable + where TId : notnull + { + protected enum ResourceKind + { + Secondary, + Relationship, + } + + private readonly IResourceRepositoryAccessor _repositoryAccessor; + private readonly INoSqlQueryLayerComposer _queryLayerComposer; + private readonly IPaginationContext _paginationContext; + private readonly IJsonApiOptions _options; + private readonly IJsonApiRequest _request; + private readonly IResourceChangeTracker _resourceChangeTracker; + private readonly IResourceGraph _resourceGraph; + private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + + private readonly TraceLogWriter> _traceWriter; + private readonly JsonApiResourceService _resourceService; + + public NoSqlResourceService( + IResourceRepositoryAccessor repositoryAccessor, + IQueryLayerComposer sqlQueryLayerComposer, + IPaginationContext paginationContext, + IJsonApiOptions options, + ILoggerFactory loggerFactory, + IJsonApiRequest request, + IResourceChangeTracker resourceChangeTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor, + INoSqlQueryLayerComposer queryLayerComposer, + IResourceGraph resourceGraph, + IEvaluatedIncludeCache evaluatedIncludeCache) + { + _repositoryAccessor = repositoryAccessor; + _paginationContext = paginationContext; + _options = options; + _request = request; + _resourceChangeTracker = resourceChangeTracker; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + + _queryLayerComposer = queryLayerComposer; + _resourceGraph = resourceGraph; + _evaluatedIncludeCache = evaluatedIncludeCache; + + _traceWriter = new TraceLogWriter>(loggerFactory); + + // Reuse JsonApiResourceService by delegation (rather than inheritance). + _resourceService = new JsonApiResourceService( + repositoryAccessor, sqlQueryLayerComposer, paginationContext, options, + loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor); + } + + #region Public API + + /// + public async Task> GetAsync(CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get resources"); + + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + if (_options.IncludeTotalResourceCount) + { + FilterExpression? topFilter = _queryLayerComposer.GetPrimaryFilterFromConstraintsForNoSql(_request.PrimaryResourceType); + _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(_request.PrimaryResourceType, topFilter, cancellationToken); + + if (_paginationContext.TotalResourceCount == 0) + { + return Array.Empty(); + } + } + + // Compose a query layer and an include expression, where the query layer can be + // safely used for getting the primary resource because the Include and Projection + // properties are Empty or null, respectively. The IncludeExpression can be used + // to fetch the included elements in separate queries. + var (queryLayer, include) = _queryLayerComposer.ComposeFromConstraintsForNoSql(_request.PrimaryResourceType); + + // Get only the primary resource. + IReadOnlyCollection resources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); + + if (queryLayer.Pagination?.PageSize != null && queryLayer.Pagination.PageSize.Value == resources.Count) + { + _paginationContext.IsPageFull = true; + } + + // Get the included elements, relying on Entity Framework Core to combine the + // entities it has fetched. + await GetIncludedElementsAsync(resources, include, cancellationToken); + + return resources; + } + + /// + public async Task GetAsync(TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id + }); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get single resource"); + + return await GetPrimaryResourceByIdWithConstraintsAsync(id, TopFieldSelection.PreserveExisting, cancellationToken); + } + + /// + public async Task GetSecondaryAsync( + TId id, + string relationshipName, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName + }); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)"); + + // Get the primary resource to (1) ensure it exists and (2) retrieve foreign key values as necessary. + IIdentifiable primary = await GetPrimaryResourceByIdAsync(id, cancellationToken); + + return await GetSecondaryAsync(primary, relationshipName, ResourceKind.Secondary, false, cancellationToken); + } + + /// + public async Task GetRelationshipAsync( + TId id, + string relationshipName, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName + }); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship"); + + // Get the primary resource to (1) ensure it exists and (2) retrieve foreign key values as necessary. + IIdentifiable primary = await GetPrimaryResourceByIdAsync(id, cancellationToken); + + return await GetSecondaryAsync(primary, relationshipName, ResourceKind.Relationship, false, cancellationToken); + } + + /// + public Task CreateAsync(TResource resource, CancellationToken cancellationToken) + { + return _resourceService.CreateAsync(resource, cancellationToken); + } + + /// + public Task AddToToManyRelationshipAsync( + TId primaryId, + string relationshipName, + ISet secondaryResourceIds, + CancellationToken cancellationToken) + { + return _resourceService.AddToToManyRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); + } + + /// + public async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + resource + }); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Update resource"); + + _resourceChangeTracker.SetRequestAttributeValues(resource); + + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); + + _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); + + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + + // TODO: Revisit. Should we wrap in try-catch clause as JsonApiResourceService? + await _repositoryAccessor.UpdateAsync(resource, resourceFromDatabase, cancellationToken); + + TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, cancellationToken); + + _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + + return !hasImplicitChanges ? null! : afterResourceFromDatabase; + } + + /// + public async Task SetRelationshipAsync( + TId leftId, + string relationshipName, + object? rightValue, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + leftId, + relationshipName, + rightValue + }); + + ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Set relationship"); + + AssertHasRelationship(_request.Relationship, relationshipName); + + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); + + await _resourceDefinitionAccessor.OnPrepareWriteAsync( + resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken); + + // TODO: Revisit. Should we wrap in try-catch clause as JsonApiResourceService? + await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, rightValue, cancellationToken); + } + + /// + public Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + return _resourceService.DeleteAsync(id, cancellationToken); + } + + /// + public async Task RemoveFromToManyRelationshipAsync( + TId leftId, + string relationshipName, + ISet rightResourceIds, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + leftId, + relationshipName, + rightResourceIds + }); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); + + TResource primaryResource = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); + + await _resourceDefinitionAccessor.OnPrepareWriteAsync( + primaryResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); + + await _repositoryAccessor.RemoveFromToManyRelationshipAsync(primaryResource, rightResourceIds, cancellationToken); + } + + #endregion Public API + + #region Implementation + + /// + /// Gets the primary resource by ID, specifying only a filter but no other constraints + /// such as include, page, or fields. + /// + /// The primary resource ID. + /// The . + /// If the primary resource does not exist. + /// The primary resource with unpopulated navigation properties. + protected async Task GetPrimaryResourceByIdAsync(TId id, CancellationToken cancellationToken) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetByIdForNoSql(id, _request.PrimaryResourceType); + + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync( + queryLayer, cancellationToken); + + return AssertPrimaryResourceExists(primaryResources.SingleOrDefault()); + } + + /// + /// Gets the primary resource by ID, observing all other constraints such as include or fields. + /// + /// The primary resource ID. + /// The . + /// The . + /// If the primary resource does not exist. + /// + /// The primary resource with navigation properties populated where such properties represent included resources. + /// + protected async Task GetPrimaryResourceByIdWithConstraintsAsync( + TId id, + TopFieldSelection fieldSelection, + CancellationToken cancellationToken) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + var (primaryLayer, include) = _queryLayerComposer.ComposeForGetByIdWithConstraintsForNoSql( + id, _request.PrimaryResourceType, fieldSelection); + + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync( + primaryLayer, cancellationToken); + + TResource primaryResource = AssertPrimaryResourceExists(primaryResources.SingleOrDefault()); + + await GetIncludedElementsAsync(primaryResources, include, cancellationToken); + + return primaryResource; + } + + /// + /// Gets the primary resource by ID, with all its fields and included resources. + /// + /// The primary resource ID. + /// The . + /// + /// The primary resource with navigation properties populated where such properties represent included resources. + /// + protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + (QueryLayer queryLayer, IncludeExpression include) = _queryLayerComposer.ComposeForUpdateForNoSql( + id, _request.PrimaryResourceType); + + TResource primaryResource = AssertPrimaryResourceExists( + await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken)); + + await GetIncludedElementsAsync(new[] { primaryResource }, include, cancellationToken); + + return primaryResource; + } + + /// + /// For each primary resource in the collection, gets + /// the secondary resources specified in the given . + /// + /// + /// An specifies one or more relationships. + /// + /// The primary resources. + /// The . + /// The . + /// + /// If any contained in the + /// is a nested expression like "first.second". + /// + /// A representing the asynchronous operation. + protected virtual async Task GetIncludedElementsAsync( + IReadOnlyCollection primaryResources, + IncludeExpression includeExpression, + CancellationToken cancellationToken) + { + _evaluatedIncludeCache.Set(includeExpression); + + foreach (var includeElementExpression in includeExpression.Elements) + { + await GetIncludedElementAsync(primaryResources, includeElementExpression, cancellationToken); + } + } + + /// + /// For each primary resource in the collection, gets + /// the secondary resources specified in the given . + /// + /// The primary resources. + /// The . + /// The . + /// + /// If the is a nested expression like "first.second". + /// + /// A representing the asynchronous operation. + protected virtual async Task GetIncludedElementAsync( + IReadOnlyCollection primaryResources, + IncludeElementExpression includeElementExpression, + CancellationToken cancellationToken) + { + if (includeElementExpression.Children.Any()) + { + throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Unsupported expression.", + Detail = "Nested include expressions are currently not supported.", + }); + } + + PropertyInfo property = includeElementExpression.Relationship.Property; + var ownsMany = Attribute.GetCustomAttribute(property, typeof(NoSqlOwnsManyAttribute)); + if (ownsMany is not null) + { + return; + } + + string relationshipName = includeElementExpression.Relationship.PublicName; + + foreach (var primaryResource in primaryResources) + { + await GetSecondaryAsync(primaryResource, relationshipName, ResourceKind.Secondary, true, cancellationToken); + } + } + + /// + /// For to-many relationships, gets the potentially empty collection of related resources. + /// For to-one relationships, gets zero or one related resource. + /// + /// The primary resource. + /// The name of the relationship between the primary and secondary resources. + /// + /// Indicates whether the relationship was specified by using "include={relationshipName}". + /// The . + /// + /// If the relationship specified by does not exist + /// or does not have a . + /// + /// + /// For to-many relationships, an of ; + /// for to-one relationships, an or . + /// + protected async Task GetSecondaryAsync( + IIdentifiable primaryResource, + string relationshipName, + ResourceKind resourceKind, + bool isIncluded, + CancellationToken cancellationToken) + { + // Get the HasMany or HasOne attribute corresponding to the given relationship name. + ResourceType resourceContext = _resourceGraph.GetResourceType(primaryResource.GetType()); + RelationshipAttribute? relationshipAttribute = resourceContext.Relationships + .SingleOrDefault(r => r.PublicName == relationshipName); + + if (relationshipAttribute is null) + { + string message = $"The relationship '{relationshipName}' does not exist."; + _traceWriter.LogMessage(() => message); + + throw new JsonApiException(new ErrorObject(HttpStatusCode.NotFound) + { + Title = "Relationship not found.", + Detail = message, + }); + } + + // Check whether the secondary resource is owned by the primary resource. + PropertyInfo property = relationshipAttribute.Property; + var ownsMany = Attribute.GetCustomAttribute(property, typeof(NoSqlOwnsManyAttribute)); + if (ownsMany is not null) + { + return relationshipAttribute.GetValue(primaryResource); + } + + // Get the HasForeignKey attribute corresponding to the relationship, if any. + var foreignKeyAttribute = (NoSqlHasForeignKeyAttribute?) Attribute.GetCustomAttribute( + relationshipAttribute.Property, + typeof(NoSqlHasForeignKeyAttribute)); + + if (foreignKeyAttribute is null) + { + string message = $"No foreign key is specified for the relationship '{relationshipName}'."; + _traceWriter.LogMessage(() => message); + + throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) + { + Title = "Invalid resource definition.", + Detail = message, + }); + } + + // Finally, get the secondary resource or resources based on the target (right) type, + // foreign key, and the information on whether the primary resource is on the dependent + // side of the foreign key relationship. + ResourceType type = relationshipAttribute.RightType; + string foreignKey = foreignKeyAttribute.PropertyName; + string stringId = primaryResource.StringId!; + bool isDependent = foreignKeyAttribute.IsDependent; + + return relationshipAttribute switch + { + HasManyAttribute => await GetManySecondaryResourcesAsync( + type, foreignKey, stringId, resourceKind, isIncluded, cancellationToken), + + HasOneAttribute when isDependent => await GetOneSecondaryResourceAsync( + type, nameof(IIdentifiable.Id), GetStringValue(primaryResource, foreignKey), + resourceKind, isIncluded, cancellationToken), + + HasOneAttribute when !isDependent => throw new JsonApiException(new ErrorObject(HttpStatusCode.NotImplemented) + { + Title = "Unsupported relationship.", + Detail = "One-to-one relationships are not yet supported.", + }), + + _ => throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) + { + Title = "Invalid relationship.", + Detail = $"The relationship '{relationshipName}' is invalid.", + }) + }; + } + + /// + /// For to-one relationships (e.g., Parent), gets the secondary resource, + /// if any, filtered by "equals({propertyName},'{propertyValue}')". + /// + /// The resource type. + /// The name of the property used to filter resources, e.g., "Id". + /// The value of the property used to filter resources, e.g., "e0bd6fe1-889e-4a06-84f8-5cf2e8d58466". + /// + /// + /// The . + /// + /// The , if it exists, or . + /// + protected async Task GetOneSecondaryResourceAsync( + ResourceType resourceType, + string propertyName, + string? propertyValue, + ResourceKind resourceKind, + bool isIncluded, + CancellationToken cancellationToken) + { + if (propertyValue is null) + { + return null; + } + + IReadOnlyCollection items = await GetManySecondaryResourcesAsync( + resourceType, propertyName, propertyValue, resourceKind, isIncluded, cancellationToken); + + return items.SingleOrDefault(); + } + + /// + /// For to-many relationships (e.g., Children), gets the collection of secondary resources, filtered + /// by the filter expressions provided in the request and by equals({propertyName},'{propertyValue}'). + /// + /// The resource type. + /// The name of the property used to filter resources, e.g., "ParentId". + /// The value of the property used to filter resources, e.g., "e0bd6fe1-889e-4a06-84f8-5cf2e8d58466". + /// + /// Indicates whether or not the secondary resource is included by way of an include expression. + /// The . + /// The potentially empty collection of secondary resources. + protected async Task> GetManySecondaryResourcesAsync( + ResourceType resourceType, + string propertyName, + string propertyValue, + ResourceKind resourceKind, + bool isIncluded, + CancellationToken cancellationToken) + { + var (queryLayer, include) = _queryLayerComposer.ComposeFromConstraintsAndFilterForNoSql( + resourceType, propertyName, propertyValue, isIncluded); + + IReadOnlyCollection items = await _repositoryAccessor.GetAsync( + resourceType, queryLayer, cancellationToken); + + if (resourceKind != ResourceKind.Relationship && !isIncluded) + { + await GetIncludedElementsAsync(items, include, cancellationToken); + } + + return items; + } + + [AssertionMethod] + private TResource AssertPrimaryResourceExists([SysNotNull] TResource? resource) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + return resource ?? throw new ResourceNotFoundException(_request.PrimaryId!, _request.PrimaryResourceType.PublicName); + } + + [AssertionMethod] + private void AssertHasRelationship([SysNotNull] RelationshipAttribute? relationship, string name) + { + if (relationship is null) + { + throw new RelationshipNotFoundException(name, _request.PrimaryResourceType!.PublicName); + } + } + + [AssertionMethod] + private static void AssertPrimaryResourceTypeInJsonApiRequestIsNotNull([SysNotNull] ResourceType? resourceType) + { + if (resourceType is null) + { + throw new InvalidOperationException( + $"Expected {nameof(IJsonApiRequest)}.{nameof(IJsonApiRequest.PrimaryResourceType)} not to be null at this point."); + } + } + + [AssertionMethod] + private static void AssertRelationshipInJsonApiRequestIsNotNull([SysNotNull] RelationshipAttribute? relationship) + { + if (relationship is null) + { + throw new InvalidOperationException($"Expected {nameof(IJsonApiRequest)}.{nameof(IJsonApiRequest.Relationship)} not to be null at this point."); + } + } + + /// + /// Gets the value of the named property. + /// + /// The resource. + /// The name of the property. + /// The value of the named property. + protected string? GetStringValue(object resource, string propertyName) + { + Type type = resource.GetType(); + PropertyInfo? property = type.GetProperty(propertyName); + + if (property is null) + { + throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) + { + Title = "Invalid property.", + Detail = $"The '{type.Name}' type does not have a '{propertyName}' property.", + }); + } + + return property.GetValue(resource)?.ToString(); + } + + #endregion Implementation + } +} From c6db07dfd99185ed08ae4fd7ddd12e992bac39fe Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Sun, 7 Nov 2021 11:55:54 +0100 Subject: [PATCH 02/34] Fix ambiguous reference in cref attribute --- .../Resources/Annotations/NoSqlResourceAttribute.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlResourceAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlResourceAttribute.cs index 7d2ef4d520..e83a671f0d 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlResourceAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlResourceAttribute.cs @@ -1,16 +1,16 @@ using System; +using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Services; +using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Resources.Annotations { /// /// When put on a resource class, marks that resource as being hosted in a NoSQL database. - /// The will - /// register a for each resource - /// having this attribute. /// + /// + /// [PublicAPI] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public class NoSqlResourceAttribute : Attribute From 37ac8f21d643c7913cdce8685f2d8704dfa612e5 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Sun, 7 Nov 2021 12:46:50 +0100 Subject: [PATCH 03/34] Fix warnings This commit disables the following warnings: - AV2310 (Code block should not contain inline comments) - AV2318 (Work-tracking TO DO comment should be removed) - AV2407 (Region should be removed) It should be allowed to add useful inline comments. In a pull request, there might be some TODOs. Regions can be useful to structure larger groups of methods, for example. Further, this commits makes code changes to remove other warnings. --- src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs | 2 +- src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs | 8 +++++--- src/JsonApiDotNetCore/Services/NoSqlResourceService.cs | 8 ++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs index 5b2437684b..0a9b530827 100644 --- a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs @@ -53,7 +53,7 @@ QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceTy /// , if the resource is a secondary resource (e.g., "/{primary}/{id}/{relationshipName}"). /// /// A tuple with a and an . - (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsAndFilterForNoSql( + (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql( ResourceType requestResourceType, string propertyName, string propertyValue, diff --git a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs index b2b80136a9..015513ed2d 100644 --- a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs @@ -8,6 +8,8 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +#pragma warning disable AV2310 // Code block should not contain inline comment + namespace JsonApiDotNetCore.Queries { /// @@ -90,14 +92,14 @@ public QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryRes Filter = new ComparisonExpression( ComparisonOperator.Equals, new ResourceFieldChainExpression( - primaryResourceType.Fields.Single(f => f.Property.Name == nameof(IIdentifiable.Id))), + primaryResourceType.Fields.Single(field => field.Property.Name == nameof(IIdentifiable.Id))), new LiteralConstantExpression(id.ToString()!)), Include = IncludeExpression.Empty, }; } /// - public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsAndFilterForNoSql( + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql( ResourceType requestResourceType, string propertyName, string propertyValue, @@ -161,7 +163,7 @@ private static FilterExpression ComposeSecondaryResourceFilter( { return new ComparisonExpression( ComparisonOperator.Equals, - new ResourceFieldChainExpression(resourceType.Fields.Single(f => f.Property.Name == propertyName)), + new ResourceFieldChainExpression(resourceType.Fields.Single(field => field.Property.Name == propertyName)), new LiteralConstantExpression(properyValue)); } diff --git a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs index 1a1be6f828..7fde412f5a 100644 --- a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs +++ b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs @@ -20,6 +20,10 @@ using Microsoft.Extensions.Logging; using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; +#pragma warning disable AV2310 // Code block should not contain inline comment +#pragma warning disable AV2318 // Work-tracking TO DO comment should be removed +#pragma warning disable AV2407 // Region should be removed + namespace JsonApiDotNetCore.Services { /// @@ -472,7 +476,7 @@ protected virtual async Task GetIncludedElementAsync( // Get the HasMany or HasOne attribute corresponding to the given relationship name. ResourceType resourceContext = _resourceGraph.GetResourceType(primaryResource.GetType()); RelationshipAttribute? relationshipAttribute = resourceContext.Relationships - .SingleOrDefault(r => r.PublicName == relationshipName); + .SingleOrDefault(relationship => relationship.PublicName == relationshipName); if (relationshipAttribute is null) { @@ -593,7 +597,7 @@ protected async Task> GetManySecondaryResourc bool isIncluded, CancellationToken cancellationToken) { - var (queryLayer, include) = _queryLayerComposer.ComposeFromConstraintsAndFilterForNoSql( + var (queryLayer, include) = _queryLayerComposer.ComposeFromConstraintsForNoSql( resourceType, propertyName, propertyValue, isIncluded); IReadOnlyCollection items = await _repositoryAccessor.GetAsync( From 05b1163fda8b36a2d23618583d318361fdbfbc8c Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Sun, 7 Nov 2021 13:28:18 +0100 Subject: [PATCH 04/34] Fix code style-related errors --- .../NoSqlServiceCollectionExtensions.cs | 6 +- .../Queries/INoSqlQueryLayerComposer.cs | 1 - .../Queries/NoSqlQueryLayerComposer.cs | 60 +++++--------- .../Services/NoSqlResourceService.cs | 82 ++++++++----------- 4 files changed, 55 insertions(+), 94 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs index f8aef34442..323bfc7935 100644 --- a/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs @@ -46,8 +46,7 @@ public static IServiceCollection AddNoSqlResourceServices(this IServiceCollectio { if (TryGetIdType(resourceType, out Type? idType)) { - services.AddScoped( - typeof(IResourceService<,>).MakeGenericType(resourceType, idType), + services.AddScoped(typeof(IResourceService<,>).MakeGenericType(resourceType, idType), typeof(NoSqlResourceService<,>).MakeGenericType(resourceType, idType)); } } @@ -57,8 +56,7 @@ public static IServiceCollection AddNoSqlResourceServices(this IServiceCollectio private static bool IsNoSqlResource(Type type) { - return Attribute.GetCustomAttribute(type, typeof(NoSqlResourceAttribute)) is not null && - type.GetInterfaces().Any(IsGenericIIdentifiable); + return Attribute.GetCustomAttribute(type, typeof(NoSqlResourceAttribute)) is not null && type.GetInterfaces().Any(IsGenericIIdentifiable); } private static bool IsGenericIIdentifiable(Type type) diff --git a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs index 0a9b530827..f53364a556 100644 --- a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs @@ -33,7 +33,6 @@ public interface INoSqlQueryLayerComposer TopFieldSelection fieldSelection) where TId : notnull; - /// /// Composes a with a filter expression in the form "equals(id,'{stringId}')". /// diff --git a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs index 015513ed2d..eb9a0cfd30 100644 --- a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs @@ -43,7 +43,8 @@ public NoSqlQueryLayerComposer( IPaginationContext paginationContext, ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache, - ISparseFieldSetCache sparseFieldSetCache) : base(constraintProviders, resourceDefinitionAccessor, options, paginationContext, targetedFields, evaluatedIncludeCache, sparseFieldSetCache) + ISparseFieldSetCache sparseFieldSetCache) + : base(constraintProviders, resourceDefinitionAccessor, options, paginationContext, targetedFields, evaluatedIncludeCache, sparseFieldSetCache) { _constraintProviders = constraintProviders; _targetedFields = targetedFields; @@ -89,12 +90,10 @@ public QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryRes { return new QueryLayer(primaryResourceType) { - Filter = new ComparisonExpression( - ComparisonOperator.Equals, - new ResourceFieldChainExpression( - primaryResourceType.Fields.Single(field => field.Property.Name == nameof(IIdentifiable.Id))), + Filter = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(primaryResourceType.Fields.Single(field => field.Property.Name == nameof(IIdentifiable.Id))), new LiteralConstantExpression(id.ToString()!)), - Include = IncludeExpression.Empty, + Include = IncludeExpression.Empty }; } @@ -108,37 +107,30 @@ public QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryRes // Compose a secondary resource filter in the form "equals({propertyName},'{propertyValue}')". FilterExpression[] secondaryResourceFilterExpressions = { - ComposeSecondaryResourceFilter(requestResourceType, propertyName, propertyValue), + ComposeSecondaryResourceFilter(requestResourceType, propertyName, propertyValue) }; // Get the query expressions from the request. - ExpressionInScope[] constraints = _constraintProviders - .SelectMany(provider => provider.GetConstraints()) - .ToArray(); + ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); bool IsQueryLayerConstraint(ExpressionInScope constraint) { - return constraint.Expression is not IncludeExpression && - (!isIncluded || (constraint.Scope is not null && - constraint.Scope.Fields.Any(field => field.PublicName == requestResourceType.PublicName))); + return constraint.Expression is not IncludeExpression && (!isIncluded || (constraint.Scope is not null && + constraint.Scope.Fields.Any(field => field.PublicName == requestResourceType.PublicName))); } - IEnumerable requestQueryExpressions = constraints - .Where(IsQueryLayerConstraint) - .Select(constraint => constraint.Expression); + IEnumerable requestQueryExpressions = constraints.Where(IsQueryLayerConstraint).Select(constraint => constraint.Expression); // Combine the secondary resource filter and request query expressions and // create the query layer from the combined query expressions. - QueryExpression[] queryExpressions = secondaryResourceFilterExpressions - .Concat(requestQueryExpressions) - .ToArray(); + QueryExpression[] queryExpressions = secondaryResourceFilterExpressions.Concat(requestQueryExpressions).ToArray(); var queryLayer = new QueryLayer(requestResourceType) { Include = IncludeExpression.Empty, Filter = GetFilter(queryExpressions, requestResourceType), Sort = GetSort(queryExpressions, requestResourceType), - Pagination = GetPagination(queryExpressions, requestResourceType), + Pagination = GetPagination(queryExpressions, requestResourceType) }; // Retrieve the IncludeExpression from the constraints collection. @@ -147,46 +139,34 @@ bool IsQueryLayerConstraint(ExpressionInScope constraint) // into a single expression. IncludeExpression include = isIncluded ? IncludeExpression.Empty - : constraints - .Select(constraint => constraint.Expression) - .OfType() - .DefaultIfEmpty(IncludeExpression.Empty) - .Single(); + : constraints.Select(constraint => constraint.Expression).OfType().DefaultIfEmpty(IncludeExpression.Empty).Single(); return (queryLayer, include); } - private static FilterExpression ComposeSecondaryResourceFilter( - ResourceType resourceType, - string propertyName, - string properyValue) + private static FilterExpression ComposeSecondaryResourceFilter(ResourceType resourceType, string propertyName, string properyValue) { - return new ComparisonExpression( - ComparisonOperator.Equals, + return new ComparisonExpression(ComparisonOperator.Equals, new ResourceFieldChainExpression(resourceType.Fields.Single(field => field.Property.Name == propertyName)), new LiteralConstantExpression(properyValue)); } - public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForUpdateForNoSql( - TId id, - ResourceType primaryResourceType) + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForUpdateForNoSql(TId id, ResourceType primaryResourceType) where TId : notnull { // Create primary layer without an include expression. AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + QueryLayer primaryLayer = new(primaryResourceType) { Include = IncludeExpression.Empty, - Filter = new ComparisonExpression( - ComparisonOperator.Equals, - new ResourceFieldChainExpression(primaryIdAttribute), - new LiteralConstantExpression(id.ToString()!)), + Filter = new ComparisonExpression(ComparisonOperator.Equals, new ResourceFieldChainExpression(primaryIdAttribute), + new LiteralConstantExpression(id.ToString()!)) }; // Create a separate include expression. ImmutableHashSet includeElements = _targetedFields.Relationships - .Select(relationship => new IncludeElementExpression(relationship)) - .ToImmutableHashSet(); + .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet(); IncludeExpression include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty; diff --git a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs index 7fde412f5a..9eb9e666db 100644 --- a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs +++ b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs @@ -55,7 +55,7 @@ public class NoSqlResourceService : IResourceService GetAsync(TId id, CancellationToken cancellationToke } /// - public async Task GetSecondaryAsync( - TId id, - string relationshipName, - CancellationToken cancellationToken) + public async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -180,10 +177,7 @@ public async Task GetAsync(TId id, CancellationToken cancellationToke } /// - public async Task GetRelationshipAsync( - TId id, - string relationshipName, - CancellationToken cancellationToken) + public async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -246,11 +240,7 @@ public Task AddToToManyRelationshipAsync( } /// - public async Task SetRelationshipAsync( - TId leftId, - string relationshipName, - object? rightValue, - CancellationToken cancellationToken) + public async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -267,8 +257,7 @@ public async Task SetRelationshipAsync( TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); - await _resourceDefinitionAccessor.OnPrepareWriteAsync( - resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken); // TODO: Revisit. Should we wrap in try-catch clause as JsonApiResourceService? await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, rightValue, cancellationToken); @@ -298,8 +287,7 @@ public async Task RemoveFromToManyRelationshipAsync( TResource primaryResource = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); - await _resourceDefinitionAccessor.OnPrepareWriteAsync( - primaryResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(primaryResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); await _repositoryAccessor.RemoveFromToManyRelationshipAsync(primaryResource, rightResourceIds, cancellationToken); } @@ -322,8 +310,7 @@ protected async Task GetPrimaryResourceByIdAsync(TId id, Cancellation QueryLayer queryLayer = _queryLayerComposer.ComposeForGetByIdForNoSql(id, _request.PrimaryResourceType); - IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync( - queryLayer, cancellationToken); + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); return AssertPrimaryResourceExists(primaryResources.SingleOrDefault()); } @@ -345,11 +332,9 @@ protected async Task GetPrimaryResourceByIdWithConstraintsAsync( { AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); - var (primaryLayer, include) = _queryLayerComposer.ComposeForGetByIdWithConstraintsForNoSql( - id, _request.PrimaryResourceType, fieldSelection); + var (primaryLayer, include) = _queryLayerComposer.ComposeForGetByIdWithConstraintsForNoSql(id, _request.PrimaryResourceType, fieldSelection); - IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync( - primaryLayer, cancellationToken); + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); TResource primaryResource = AssertPrimaryResourceExists(primaryResources.SingleOrDefault()); @@ -370,13 +355,14 @@ protected async Task GetPrimaryResourceForUpdateAsync(TId id, Cancell { AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); - (QueryLayer queryLayer, IncludeExpression include) = _queryLayerComposer.ComposeForUpdateForNoSql( - id, _request.PrimaryResourceType); + (QueryLayer queryLayer, IncludeExpression include) = _queryLayerComposer.ComposeForUpdateForNoSql(id, _request.PrimaryResourceType); - TResource primaryResource = AssertPrimaryResourceExists( - await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken)); + TResource primaryResource = AssertPrimaryResourceExists(await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken)); - await GetIncludedElementsAsync(new[] { primaryResource }, include, cancellationToken); + await GetIncludedElementsAsync(new[] + { + primaryResource + }, include, cancellationToken); return primaryResource; } @@ -430,12 +416,13 @@ protected virtual async Task GetIncludedElementAsync( throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { Title = "Unsupported expression.", - Detail = "Nested include expressions are currently not supported.", + Detail = "Nested include expressions are currently not supported." }); } PropertyInfo property = includeElementExpression.Relationship.Property; var ownsMany = Attribute.GetCustomAttribute(property, typeof(NoSqlOwnsManyAttribute)); + if (ownsMany is not null) { return; @@ -475,8 +462,9 @@ protected virtual async Task GetIncludedElementAsync( { // Get the HasMany or HasOne attribute corresponding to the given relationship name. ResourceType resourceContext = _resourceGraph.GetResourceType(primaryResource.GetType()); - RelationshipAttribute? relationshipAttribute = resourceContext.Relationships - .SingleOrDefault(relationship => relationship.PublicName == relationshipName); + + RelationshipAttribute? relationshipAttribute = + resourceContext.Relationships.SingleOrDefault(relationship => relationship.PublicName == relationshipName); if (relationshipAttribute is null) { @@ -486,22 +474,22 @@ protected virtual async Task GetIncludedElementAsync( throw new JsonApiException(new ErrorObject(HttpStatusCode.NotFound) { Title = "Relationship not found.", - Detail = message, + Detail = message }); } // Check whether the secondary resource is owned by the primary resource. PropertyInfo property = relationshipAttribute.Property; var ownsMany = Attribute.GetCustomAttribute(property, typeof(NoSqlOwnsManyAttribute)); + if (ownsMany is not null) { return relationshipAttribute.GetValue(primaryResource); } // Get the HasForeignKey attribute corresponding to the relationship, if any. - var foreignKeyAttribute = (NoSqlHasForeignKeyAttribute?) Attribute.GetCustomAttribute( - relationshipAttribute.Property, - typeof(NoSqlHasForeignKeyAttribute)); + var foreignKeyAttribute = (NoSqlHasForeignKeyAttribute?)Attribute.GetCustomAttribute( + relationshipAttribute.Property, typeof(NoSqlHasForeignKeyAttribute)); if (foreignKeyAttribute is null) { @@ -511,7 +499,7 @@ protected virtual async Task GetIncludedElementAsync( throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) { Title = "Invalid resource definition.", - Detail = message, + Detail = message }); } @@ -525,23 +513,21 @@ protected virtual async Task GetIncludedElementAsync( return relationshipAttribute switch { - HasManyAttribute => await GetManySecondaryResourcesAsync( - type, foreignKey, stringId, resourceKind, isIncluded, cancellationToken), + HasManyAttribute => await GetManySecondaryResourcesAsync(type, foreignKey, stringId, resourceKind, isIncluded, cancellationToken), - HasOneAttribute when isDependent => await GetOneSecondaryResourceAsync( - type, nameof(IIdentifiable.Id), GetStringValue(primaryResource, foreignKey), - resourceKind, isIncluded, cancellationToken), + HasOneAttribute when isDependent => await GetOneSecondaryResourceAsync(type, nameof(IIdentifiable.Id), + GetStringValue(primaryResource, foreignKey), resourceKind, isIncluded, cancellationToken), HasOneAttribute when !isDependent => throw new JsonApiException(new ErrorObject(HttpStatusCode.NotImplemented) { Title = "Unsupported relationship.", - Detail = "One-to-one relationships are not yet supported.", + Detail = "One-to-one relationships are not yet supported." }), _ => throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) { Title = "Invalid relationship.", - Detail = $"The relationship '{relationshipName}' is invalid.", + Detail = $"The relationship '{relationshipName}' is invalid." }) }; } @@ -597,11 +583,9 @@ protected async Task> GetManySecondaryResourc bool isIncluded, CancellationToken cancellationToken) { - var (queryLayer, include) = _queryLayerComposer.ComposeFromConstraintsForNoSql( - resourceType, propertyName, propertyValue, isIncluded); + var (queryLayer, include) = _queryLayerComposer.ComposeFromConstraintsForNoSql(resourceType, propertyName, propertyValue, isIncluded); - IReadOnlyCollection items = await _repositoryAccessor.GetAsync( - resourceType, queryLayer, cancellationToken); + IReadOnlyCollection items = await _repositoryAccessor.GetAsync(resourceType, queryLayer, cancellationToken); if (resourceKind != ResourceKind.Relationship && !isIncluded) { @@ -663,7 +647,7 @@ private static void AssertRelationshipInJsonApiRequestIsNotNull([SysNotNull] Rel throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) { Title = "Invalid property.", - Detail = $"The '{type.Name}' type does not have a '{propertyName}' property.", + Detail = $"The '{type.Name}' type does not have a '{propertyName}' property." }); } From fd237a006505b7abb625af6d00b434589efdc151 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Sun, 7 Nov 2021 14:03:26 +0100 Subject: [PATCH 05/34] Fix false-positive warning on possible multiple enumeration --- .../Queries/NoSqlQueryLayerComposer.cs | 5 +++++ src/JsonApiDotNetCore/Services/NoSqlResourceService.cs | 10 ++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs index eb9a0cfd30..d3a4f7cd4f 100644 --- a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +#pragma warning disable AV1551 // Method overload should call another overload #pragma warning disable AV2310 // Code block should not contain inline comment namespace JsonApiDotNetCore.Queries @@ -36,6 +37,8 @@ public class NoSqlQueryLayerComposer : QueryLayerComposer, INoSqlQueryLayerCompo private readonly IEnumerable _constraintProviders; private readonly ITargetedFields _targetedFields; + // ReSharper disable PossibleMultipleEnumeration + public NoSqlQueryLayerComposer( IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, @@ -50,6 +53,8 @@ public NoSqlQueryLayerComposer( _targetedFields = targetedFields; } + // ReSharper restore PossibleMultipleEnumeration + /// public FilterExpression? GetPrimaryFilterFromConstraintsForNoSql(ResourceType primaryResourceType) { diff --git a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs index 9eb9e666db..cd0528ac09 100644 --- a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs +++ b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs @@ -20,6 +20,7 @@ using Microsoft.Extensions.Logging; using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; +#pragma warning disable AV1551 // Method overload should call another overload #pragma warning disable AV2310 // Code block should not contain inline comment #pragma warning disable AV2318 // Work-tracking TO DO comment should be removed #pragma warning disable AV2407 // Region should be removed @@ -642,16 +643,13 @@ private static void AssertRelationshipInJsonApiRequestIsNotNull([SysNotNull] Rel Type type = resource.GetType(); PropertyInfo? property = type.GetProperty(propertyName); - if (property is null) - { - throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) + return property is not null + ? property.GetValue(resource)?.ToString() + : throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) { Title = "Invalid property.", Detail = $"The '{type.Name}' type does not have a '{propertyName}' property." }); - } - - return property.GetValue(resource)?.ToString(); } #endregion Implementation From 71e437d27ad9153d412a450a7f94ca889d074c56 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Sun, 7 Nov 2021 14:54:42 +0100 Subject: [PATCH 06/34] Remove isIncluded parameter --- .../Services/NoSqlResourceService.cs | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs index cd0528ac09..795ac2a232 100644 --- a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs +++ b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs @@ -56,6 +56,7 @@ public class NoSqlResourceService : IResourceService GetAsync(TId id, CancellationToken cancellationToke // Get the primary resource to (1) ensure it exists and (2) retrieve foreign key values as necessary. IIdentifiable primary = await GetPrimaryResourceByIdAsync(id, cancellationToken); - return await GetSecondaryAsync(primary, relationshipName, ResourceKind.Secondary, false, cancellationToken); + return await GetSecondaryAsync(primary, relationshipName, ResourceKind.Secondary, cancellationToken); } /// @@ -191,7 +192,7 @@ public async Task GetAsync(TId id, CancellationToken cancellationToke // Get the primary resource to (1) ensure it exists and (2) retrieve foreign key values as necessary. IIdentifiable primary = await GetPrimaryResourceByIdAsync(id, cancellationToken); - return await GetSecondaryAsync(primary, relationshipName, ResourceKind.Relationship, false, cancellationToken); + return await GetSecondaryAsync(primary, relationshipName, ResourceKind.Relationship, cancellationToken); } /// @@ -433,7 +434,7 @@ protected virtual async Task GetIncludedElementAsync( foreach (var primaryResource in primaryResources) { - await GetSecondaryAsync(primaryResource, relationshipName, ResourceKind.Secondary, true, cancellationToken); + await GetSecondaryAsync(primaryResource, relationshipName, ResourceKind.Included, cancellationToken); } } @@ -444,7 +445,6 @@ protected virtual async Task GetIncludedElementAsync( /// The primary resource. /// The name of the relationship between the primary and secondary resources. /// - /// Indicates whether the relationship was specified by using "include={relationshipName}". /// The . /// /// If the relationship specified by does not exist @@ -458,7 +458,6 @@ protected virtual async Task GetIncludedElementAsync( IIdentifiable primaryResource, string relationshipName, ResourceKind resourceKind, - bool isIncluded, CancellationToken cancellationToken) { // Get the HasMany or HasOne attribute corresponding to the given relationship name. @@ -514,10 +513,10 @@ protected virtual async Task GetIncludedElementAsync( return relationshipAttribute switch { - HasManyAttribute => await GetManySecondaryResourcesAsync(type, foreignKey, stringId, resourceKind, isIncluded, cancellationToken), + HasManyAttribute => await GetManySecondaryResourcesAsync(type, foreignKey, stringId, resourceKind, cancellationToken), HasOneAttribute when isDependent => await GetOneSecondaryResourceAsync(type, nameof(IIdentifiable.Id), - GetStringValue(primaryResource, foreignKey), resourceKind, isIncluded, cancellationToken), + GetStringValue(primaryResource, foreignKey), resourceKind, cancellationToken), HasOneAttribute when !isDependent => throw new JsonApiException(new ErrorObject(HttpStatusCode.NotImplemented) { @@ -541,7 +540,6 @@ protected virtual async Task GetIncludedElementAsync( /// The name of the property used to filter resources, e.g., "Id". /// The value of the property used to filter resources, e.g., "e0bd6fe1-889e-4a06-84f8-5cf2e8d58466". /// - /// /// The . /// /// The , if it exists, or . @@ -551,7 +549,6 @@ protected virtual async Task GetIncludedElementAsync( string propertyName, string? propertyValue, ResourceKind resourceKind, - bool isIncluded, CancellationToken cancellationToken) { if (propertyValue is null) @@ -560,7 +557,7 @@ protected virtual async Task GetIncludedElementAsync( } IReadOnlyCollection items = await GetManySecondaryResourcesAsync( - resourceType, propertyName, propertyValue, resourceKind, isIncluded, cancellationToken); + resourceType, propertyName, propertyValue, resourceKind, cancellationToken); return items.SingleOrDefault(); } @@ -573,7 +570,6 @@ protected virtual async Task GetIncludedElementAsync( /// The name of the property used to filter resources, e.g., "ParentId". /// The value of the property used to filter resources, e.g., "e0bd6fe1-889e-4a06-84f8-5cf2e8d58466". /// - /// Indicates whether or not the secondary resource is included by way of an include expression. /// The . /// The potentially empty collection of secondary resources. protected async Task> GetManySecondaryResourcesAsync( @@ -581,14 +577,14 @@ protected async Task> GetManySecondaryResourc string propertyName, string propertyValue, ResourceKind resourceKind, - bool isIncluded, CancellationToken cancellationToken) { + bool isIncluded = resourceKind == ResourceKind.Included; var (queryLayer, include) = _queryLayerComposer.ComposeFromConstraintsForNoSql(resourceType, propertyName, propertyValue, isIncluded); IReadOnlyCollection items = await _repositoryAccessor.GetAsync(resourceType, queryLayer, cancellationToken); - if (resourceKind != ResourceKind.Relationship && !isIncluded) + if (resourceKind == ResourceKind.Secondary) { await GetIncludedElementsAsync(items, include, cancellationToken); } From 19a6dd5c095c35f866f725b6250d3c850a90f453 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Sun, 7 Nov 2021 15:19:56 +0100 Subject: [PATCH 07/34] Manually perform JADNC Full Cleanup --- .../NoSqlServiceCollectionExtensions.cs | 33 +-- .../Queries/INoSqlQueryLayerComposer.cs | 44 ++-- .../Queries/NoSqlQueryLayerComposer.cs | 3 +- .../NoSqlHasForeignKeyAttribute.cs | 16 +- .../Annotations/NoSqlOwnsManyAttribute.cs | 17 +- .../Annotations/NoSqlResourceAttribute.cs | 4 +- .../Services/NoSqlResourceService.cs | 203 +++++++++++------- 7 files changed, 189 insertions(+), 131 deletions(-) diff --git a/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs index 323bfc7935..8d691672d0 100644 --- a/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/NoSqlServiceCollectionExtensions.cs @@ -12,32 +12,39 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Provides extension methods for the registration of services and other injectables - /// with the service container. + /// Provides extension methods for the registration of services and other injectables with the service container. /// [PublicAPI] public static class NoSqlServiceCollectionExtensions { /// - /// For each resource annotated with the , adds a - /// scoped service with a service type of - /// and an implementation type of . + /// For each resource annotated with the , adds a scoped service with a service type of + /// and an implementation type of . /// - /// The . - /// The . + /// + /// The . + /// + /// + /// The . + /// public static IServiceCollection AddNoSqlResourceServices(this IServiceCollection services) { return services.AddNoSqlResourceServices(Assembly.GetCallingAssembly()); } /// - /// For each resource annotated with the , adds a - /// scoped service with a service type of - /// and an implementation type of . + /// For each resource annotated with the , adds a scoped service with a service type of + /// and an implementation type of . /// - /// The . - /// The containing the annotated resources. - /// The . + /// + /// The . + /// + /// + /// The containing the annotated resources. + /// + /// + /// The . + /// public static IServiceCollection AddNoSqlResourceServices(this IServiceCollection services, Assembly assembly) { services.AddScoped(); diff --git a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs index f53364a556..f008d25129 100644 --- a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs @@ -5,27 +5,26 @@ namespace JsonApiDotNetCore.Queries { /// - /// Takes scoped expressions from s and transforms them. - /// Additionally provides specific transformations for NoSQL databases without support for joins. + /// Takes scoped expressions from s and transforms them. Additionally provides specific transformations for NoSQL + /// databases without support for joins. /// [PublicAPI] public interface INoSqlQueryLayerComposer { /// - /// Builds a filter from constraints, used to determine total resource count on a primary - /// collection endpoint. + /// Builds a filter from constraints, used to determine total resource count on a primary collection endpoint. /// FilterExpression? GetPrimaryFilterFromConstraintsForNoSql(ResourceType primaryResourceType); /// - /// Composes a and an from the - /// constraints specified by the request. Used for primary resources. + /// Composes a and an from the constraints specified by the request. Used for primary + /// resources. /// (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType); /// - /// Composes a and an from the - /// constraints specified by the request. Used for primary resources. + /// Composes a and an from the constraints specified by the request. Used for primary + /// resources. /// (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql( TId id, @@ -40,18 +39,25 @@ QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceTy where TId : notnull; /// - /// Composes a from the constraints specified by the request - /// and a filter expression in the form "equals({propertyName},'{propertyValue}')". - /// Used for secondary or included resources. + /// Composes a from the constraints specified by the request and a filter expression in the form + /// "equals({propertyName},'{propertyValue}')". Used for secondary or included resources. /// - /// The of the secondary or included resource. - /// The name of the property of the secondary or included resource used for filtering. - /// The value of the property of the secondary or included resource used for filtering. + /// + /// The of the secondary or included resource. + /// + /// + /// The name of the property of the secondary or included resource used for filtering. + /// + /// + /// The value of the property of the secondary or included resource used for filtering. + /// /// - /// , if the resource is included by the request (e.g., "{url}?include={relationshipName}"); - /// , if the resource is a secondary resource (e.g., "/{primary}/{id}/{relationshipName}"). + /// , if the resource is included by the request (e.g., "{url}?include={relationshipName}"); , if the + /// resource is a secondary resource (e.g., "/{primary}/{id}/{relationshipName}"). /// - /// A tuple with a and an . + /// + /// A tuple with a and an . + /// (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql( ResourceType requestResourceType, string propertyName, @@ -59,8 +65,8 @@ QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceTy bool isIncluded); /// - /// Builds a query that retrieves the primary resource, including all of its attributes - /// and all targeted relationships, during a create/update/delete request. + /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete + /// request. /// (QueryLayer QueryLayer, IncludeExpression Include) ComposeForUpdateForNoSql(TId id, ResourceType primaryResourceType) where TId : notnull; diff --git a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs index d3a4f7cd4f..898c04271d 100644 --- a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs @@ -17,8 +17,7 @@ namespace JsonApiDotNetCore.Queries /// Default implementation of the . /// /// - /// Register with the service container as - /// shown in the following example. + /// Register with the service container as shown in the following example. /// /// /// - /// Gets the name of the foreign key property corresponding to the annotated - /// navigation property. + /// Gets the name of the foreign key property corresponding to the annotated navigation property. /// public string PropertyName { get; private set; } /// - /// Gets or sets a value indicating whether the navigation property is on - /// the dependent side of the foreign key relationship. The default is + /// Gets or sets a value indicating whether the navigation property is on the dependent side of the foreign key relationship. The default is /// . /// public bool IsDependent { get; set; } = true; + + public NoSqlHasForeignKeyAttribute(string propertyName) + { + PropertyName = propertyName; + } } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs index 43dccc784f..fdebf28d3c 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs @@ -4,19 +4,14 @@ namespace JsonApiDotNetCore.Resources.Annotations { /// - /// Used to provide additional information for a JSON:API relationship - /// (https://jsonapi.org/format/#document-resource-object-relationships). + /// Used to provide additional information for a JSON:API relationship (https://jsonapi.org/format/#document-resource-object-relationships). /// /// - /// With Cosmos DB, for example, an entity can own one or many other entity types. - /// In the example below, an Author entity owns many Article entities, which is also - /// reflected in the Entity Framework Core model. This means that all owned Article - /// entities will be returned with the Author. To represent Article entities as a - /// to-many relationship, the [HasMany] and [NoSqlOwnsMany] annotations can be combined - /// to indicate to the NoSQL resource service that those Article resources do not have - /// to be fetched. To include the Article resources into the response, the request will - /// have to add an include expression such as "?include=articles". Otherwise, the - /// Article resources will not be returned. + /// With Cosmos DB, for example, an entity can own one or many other entity types. In the example below, an Author entity owns many Article entities, + /// which is also reflected in the Entity Framework Core model. This means that all owned Article entities will be returned with the Author. To represent + /// Article entities as a to-many relationship, the [HasMany] and [NoSqlOwnsMany] annotations can be combined to indicate to the NoSQL resource service + /// that those Article resources do not have to be fetched. To include the Article resources into the response, the request will have to add an include + /// expression such as "?include=articles". Otherwise, the Article resources will not be returned. /// /// /// /// When put on a resource class, marks that resource as being hosted in a NoSQL database. /// - /// - /// + /// + /// [PublicAPI] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] public class NoSqlResourceAttribute : Attribute diff --git a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs index 795ac2a232..eaba978484 100644 --- a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs +++ b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs @@ -23,17 +23,14 @@ #pragma warning disable AV1551 // Method overload should call another overload #pragma warning disable AV2310 // Code block should not contain inline comment #pragma warning disable AV2318 // Work-tracking TO DO comment should be removed -#pragma warning disable AV2407 // Region should be removed namespace JsonApiDotNetCore.Services { /// - /// Provides the resource services where a NoSQL database such as Cosmos DB or MongoDB - /// is used as a back-end database. + /// Provides the resource services where a NoSQL database such as Cosmos DB or MongoDB is used as a back-end database. /// /// - /// Register with the service container - /// as shown in the example. + /// Register with the service container as shown in the example. /// /// /// /// - /// The type of the resource. - /// The type of the resource Id. + /// + /// The type of the resource. + /// + /// + /// The type of the resource Id. + /// [PublicAPI] public class NoSqlResourceService : IResourceService where TResource : class, IIdentifiable where TId : notnull { - protected enum ResourceKind - { - Secondary, - Included, - Relationship - } - private readonly IResourceRepositoryAccessor _repositoryAccessor; private readonly INoSqlQueryLayerComposer _queryLayerComposer; private readonly IPaginationContext _paginationContext; @@ -100,13 +94,10 @@ public NoSqlResourceService( _traceWriter = new TraceLogWriter>(loggerFactory); // Reuse JsonApiResourceService by delegation (rather than inheritance). - _resourceService = new JsonApiResourceService( - repositoryAccessor, sqlQueryLayerComposer, paginationContext, options, - loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor); + _resourceService = new JsonApiResourceService(repositoryAccessor, sqlQueryLayerComposer, paginationContext, options, loggerFactory, + request, resourceChangeTracker, resourceDefinitionAccessor); } - #region Public API - /// public async Task> GetAsync(CancellationToken cancellationToken) { @@ -294,18 +285,21 @@ public async Task RemoveFromToManyRelationshipAsync( await _repositoryAccessor.RemoveFromToManyRelationshipAsync(primaryResource, rightResourceIds, cancellationToken); } - #endregion Public API - - #region Implementation - /// - /// Gets the primary resource by ID, specifying only a filter but no other constraints - /// such as include, page, or fields. + /// Gets the primary resource by ID, specifying only a filter but no other constraints such as include, page, or fields. /// - /// The primary resource ID. - /// The . - /// If the primary resource does not exist. - /// The primary resource with unpopulated navigation properties. + /// + /// The primary resource ID. + /// + /// + /// The . + /// + /// + /// If the primary resource does not exist. + /// + /// + /// The primary resource with unpopulated navigation properties. + /// protected async Task GetPrimaryResourceByIdAsync(TId id, CancellationToken cancellationToken) { AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); @@ -320,10 +314,18 @@ protected async Task GetPrimaryResourceByIdAsync(TId id, Cancellation /// /// Gets the primary resource by ID, observing all other constraints such as include or fields. /// - /// The primary resource ID. - /// The . - /// The . - /// If the primary resource does not exist. + /// + /// The primary resource ID. + /// + /// + /// The . + /// + /// + /// The . + /// + /// + /// If the primary resource does not exist. + /// /// /// The primary resource with navigation properties populated where such properties represent included resources. /// @@ -348,8 +350,12 @@ protected async Task GetPrimaryResourceByIdWithConstraintsAsync( /// /// Gets the primary resource by ID, with all its fields and included resources. /// - /// The primary resource ID. - /// The . + /// + /// The primary resource ID. + /// + /// + /// The . + /// /// /// The primary resource with navigation properties populated where such properties represent included resources. /// @@ -370,20 +376,27 @@ await GetIncludedElementsAsync(new[] } /// - /// For each primary resource in the collection, gets - /// the secondary resources specified in the given . + /// For each primary resource in the collection, gets the secondary resources specified in the given + /// . /// /// /// An specifies one or more relationships. /// - /// The primary resources. - /// The . - /// The . + /// + /// The primary resources. + /// + /// + /// The . + /// + /// + /// The . + /// /// - /// If any contained in the - /// is a nested expression like "first.second". + /// If any contained in the is a nested expression like "first.second". /// - /// A representing the asynchronous operation. + /// + /// A representing the asynchronous operation. + /// protected virtual async Task GetIncludedElementsAsync( IReadOnlyCollection primaryResources, IncludeExpression includeExpression, @@ -398,16 +411,24 @@ protected virtual async Task GetIncludedElementsAsync( } /// - /// For each primary resource in the collection, gets - /// the secondary resources specified in the given . + /// For each primary resource in the collection, gets the secondary resources specified in the given + /// . /// - /// The primary resources. - /// The . - /// The . + /// + /// The primary resources. + /// + /// + /// The . + /// + /// + /// The . + /// /// /// If the is a nested expression like "first.second". /// - /// A representing the asynchronous operation. + /// + /// A representing the asynchronous operation. + /// protected virtual async Task GetIncludedElementAsync( IReadOnlyCollection primaryResources, IncludeElementExpression includeElementExpression, @@ -439,20 +460,24 @@ protected virtual async Task GetIncludedElementAsync( } /// - /// For to-many relationships, gets the potentially empty collection of related resources. - /// For to-one relationships, gets zero or one related resource. + /// For to-many relationships, gets the potentially empty collection of related resources. For to-one relationships, gets zero or one related resource. /// - /// The primary resource. - /// The name of the relationship between the primary and secondary resources. + /// + /// The primary resource. + /// + /// + /// The name of the relationship between the primary and secondary resources. + /// /// - /// The . + /// + /// The . + /// /// - /// If the relationship specified by does not exist - /// or does not have a . + /// If the relationship specified by does not exist or does not have a . /// /// - /// For to-many relationships, an of ; - /// for to-one relationships, an or . + /// For to-many relationships, an of ; for to-one relationships, an + /// or . /// protected async Task GetSecondaryAsync( IIdentifiable primaryResource, @@ -533,14 +558,21 @@ protected virtual async Task GetIncludedElementAsync( } /// - /// For to-one relationships (e.g., Parent), gets the secondary resource, - /// if any, filtered by "equals({propertyName},'{propertyValue}')". + /// For to-one relationships (e.g., Parent), gets the secondary resource, if any, filtered by "equals({propertyName},'{propertyValue}')". /// - /// The resource type. - /// The name of the property used to filter resources, e.g., "Id". - /// The value of the property used to filter resources, e.g., "e0bd6fe1-889e-4a06-84f8-5cf2e8d58466". + /// + /// The resource type. + /// + /// + /// The name of the property used to filter resources, e.g., "Id". + /// + /// + /// The value of the property used to filter resources, e.g., "e0bd6fe1-889e-4a06-84f8-5cf2e8d58466". + /// /// - /// The . + /// + /// The . + /// /// /// The , if it exists, or . /// @@ -563,15 +595,25 @@ protected virtual async Task GetIncludedElementAsync( } /// - /// For to-many relationships (e.g., Children), gets the collection of secondary resources, filtered - /// by the filter expressions provided in the request and by equals({propertyName},'{propertyValue}'). + /// For to-many relationships (e.g., Children), gets the collection of secondary resources, filtered by the filter expressions provided in the request + /// and by equals({propertyName},'{propertyValue}'). /// - /// The resource type. - /// The name of the property used to filter resources, e.g., "ParentId". - /// The value of the property used to filter resources, e.g., "e0bd6fe1-889e-4a06-84f8-5cf2e8d58466". + /// + /// The resource type. + /// + /// + /// The name of the property used to filter resources, e.g., "ParentId". + /// + /// + /// The value of the property used to filter resources, e.g., "e0bd6fe1-889e-4a06-84f8-5cf2e8d58466". + /// /// - /// The . - /// The potentially empty collection of secondary resources. + /// + /// The . + /// + /// + /// The potentially empty collection of secondary resources. + /// protected async Task> GetManySecondaryResourcesAsync( ResourceType resourceType, string propertyName, @@ -631,9 +673,15 @@ private static void AssertRelationshipInJsonApiRequestIsNotNull([SysNotNull] Rel /// /// Gets the value of the named property. /// - /// The resource. - /// The name of the property. - /// The value of the named property. + /// + /// The resource. + /// + /// + /// The name of the property. + /// + /// + /// The value of the named property. + /// protected string? GetStringValue(object resource, string propertyName) { Type type = resource.GetType(); @@ -648,6 +696,11 @@ private static void AssertRelationshipInJsonApiRequestIsNotNull([SysNotNull] Rel }); } - #endregion Implementation + protected enum ResourceKind + { + Secondary, + Included, + Relationship + } } } From 50bb3ec23c67c764072be542b4682ed2aa52b7be Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 8 Nov 2021 10:28:25 +0100 Subject: [PATCH 08/34] Manually edit files to comply with formatting rules This commit edits files that lead to diff errors on appveyor even after having performed the ReSharper code cleanup locally (a) using the ReSharper UI, (b) the PowerShell script, and (c) the exact command line executed on appveyor. Let's hope for the best. --- .../Queries/INoSqlQueryLayerComposer.cs | 11 +--- .../Queries/NoSqlQueryLayerComposer.cs | 20 ++---- .../NoSqlHasForeignKeyAttribute.cs | 19 ------ .../Services/NoSqlResourceService.cs | 61 +++++-------------- 4 files changed, 23 insertions(+), 88 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs index f008d25129..4f7f2c0e7c 100644 --- a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs @@ -26,9 +26,7 @@ public interface INoSqlQueryLayerComposer /// Composes a and an from the constraints specified by the request. Used for primary /// resources. /// - (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql( - TId id, - ResourceType primaryResourceType, + (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection) where TId : notnull; @@ -58,11 +56,8 @@ QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceTy /// /// A tuple with a and an . /// - (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql( - ResourceType requestResourceType, - string propertyName, - string propertyValue, - bool isIncluded); + (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType, string propertyName, + string propertyValue, bool isIncluded); /// /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete diff --git a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs index 898c04271d..4cc32af1b6 100644 --- a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs @@ -38,13 +38,8 @@ public class NoSqlQueryLayerComposer : QueryLayerComposer, INoSqlQueryLayerCompo // ReSharper disable PossibleMultipleEnumeration - public NoSqlQueryLayerComposer( - IEnumerable constraintProviders, - IResourceDefinitionAccessor resourceDefinitionAccessor, - IJsonApiOptions options, - IPaginationContext paginationContext, - ITargetedFields targetedFields, - IEvaluatedIncludeCache evaluatedIncludeCache, + public NoSqlQueryLayerComposer(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, + IJsonApiOptions options, IPaginationContext paginationContext, ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache, ISparseFieldSetCache sparseFieldSetCache) : base(constraintProviders, resourceDefinitionAccessor, options, paginationContext, targetedFields, evaluatedIncludeCache, sparseFieldSetCache) { @@ -73,9 +68,7 @@ public NoSqlQueryLayerComposer( } /// - public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql( - TId id, - ResourceType primaryResourceType, + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection) where TId : notnull { @@ -102,11 +95,8 @@ public QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryRes } /// - public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql( - ResourceType requestResourceType, - string propertyName, - string propertyValue, - bool isIncluded) + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType, string propertyName, + string propertyValue, bool isIncluded) { // Compose a secondary resource filter in the form "equals({propertyName},'{propertyValue}')". FilterExpression[] secondaryResourceFilterExpressions = diff --git a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlHasForeignKeyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlHasForeignKeyAttribute.cs index f3d2c150f9..e877781f44 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlHasForeignKeyAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlHasForeignKeyAttribute.cs @@ -7,25 +7,6 @@ namespace JsonApiDotNetCore.Resources.Annotations /// Used to provide additional information for a JSON:API relationship with a foreign key /// (https://jsonapi.org/format/#document-resource-object-relationships). /// - /// - /// - /// { - /// [HasMany] - /// [NoSqlHasForeignKey(nameof(Article.AuthorId))] - /// public ICollection
Articles { get; set; } - /// } - /// - /// public class Article : Identifiable - /// { - /// public Guid AuthorId { get; set; } - /// - /// [HasOne] - /// [NoSqlHasForeignKey(nameof(AuthorId))] - /// public Author Author { get; set; } - /// } - /// ]]> - /// [PublicAPI] [AttributeUsage(AttributeTargets.Property)] public class NoSqlHasForeignKeyAttribute : Attribute diff --git a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs index eaba978484..920fb6e3a4 100644 --- a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs +++ b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs @@ -67,18 +67,10 @@ public class NoSqlResourceService : IResourceService> _traceWriter; private readonly JsonApiResourceService _resourceService; - public NoSqlResourceService( - IResourceRepositoryAccessor repositoryAccessor, - IQueryLayerComposer sqlQueryLayerComposer, - IPaginationContext paginationContext, - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor, - INoSqlQueryLayerComposer queryLayerComposer, - IResourceGraph resourceGraph, - IEvaluatedIncludeCache evaluatedIncludeCache) + public NoSqlResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer sqlQueryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, + IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor, + INoSqlQueryLayerComposer queryLayerComposer, IResourceGraph resourceGraph, IEvaluatedIncludeCache evaluatedIncludeCache) { _repositoryAccessor = repositoryAccessor; _paginationContext = paginationContext; @@ -193,10 +185,7 @@ public async Task GetAsync(TId id, CancellationToken cancellationToke } /// - public Task AddToToManyRelationshipAsync( - TId primaryId, - string relationshipName, - ISet secondaryResourceIds, + public Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, CancellationToken cancellationToken) { return _resourceService.AddToToManyRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); @@ -263,10 +252,7 @@ public Task DeleteAsync(TId id, CancellationToken cancellationToken) } /// - public async Task RemoveFromToManyRelationshipAsync( - TId leftId, - string relationshipName, - ISet rightResourceIds, + public async Task RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new @@ -329,9 +315,7 @@ protected async Task GetPrimaryResourceByIdAsync(TId id, Cancellation /// /// The primary resource with navigation properties populated where such properties represent included resources. /// - protected async Task GetPrimaryResourceByIdWithConstraintsAsync( - TId id, - TopFieldSelection fieldSelection, + protected async Task GetPrimaryResourceByIdWithConstraintsAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) { AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); @@ -397,9 +381,7 @@ await GetIncludedElementsAsync(new[] /// /// A representing the asynchronous operation. /// - protected virtual async Task GetIncludedElementsAsync( - IReadOnlyCollection primaryResources, - IncludeExpression includeExpression, + protected virtual async Task GetIncludedElementsAsync(IReadOnlyCollection primaryResources, IncludeExpression includeExpression, CancellationToken cancellationToken) { _evaluatedIncludeCache.Set(includeExpression); @@ -429,10 +411,8 @@ protected virtual async Task GetIncludedElementsAsync( /// /// A representing the asynchronous operation. /// - protected virtual async Task GetIncludedElementAsync( - IReadOnlyCollection primaryResources, - IncludeElementExpression includeElementExpression, - CancellationToken cancellationToken) + protected virtual async Task GetIncludedElementAsync(IReadOnlyCollection primaryResources, + IncludeElementExpression includeElementExpression, CancellationToken cancellationToken) { if (includeElementExpression.Children.Any()) { @@ -479,10 +459,7 @@ protected virtual async Task GetIncludedElementAsync( /// For to-many relationships, an of ; for to-one relationships, an /// or . /// - protected async Task GetSecondaryAsync( - IIdentifiable primaryResource, - string relationshipName, - ResourceKind resourceKind, + protected async Task GetSecondaryAsync(IIdentifiable primaryResource, string relationshipName, ResourceKind resourceKind, CancellationToken cancellationToken) { // Get the HasMany or HasOne attribute corresponding to the given relationship name. @@ -576,12 +553,8 @@ protected virtual async Task GetIncludedElementAsync( /// /// The , if it exists, or . /// - protected async Task GetOneSecondaryResourceAsync( - ResourceType resourceType, - string propertyName, - string? propertyValue, - ResourceKind resourceKind, - CancellationToken cancellationToken) + protected async Task GetOneSecondaryResourceAsync(ResourceType resourceType, string propertyName, string? propertyValue, + ResourceKind resourceKind, CancellationToken cancellationToken) { if (propertyValue is null) { @@ -614,12 +587,8 @@ protected virtual async Task GetIncludedElementAsync( /// /// The potentially empty collection of secondary resources. /// - protected async Task> GetManySecondaryResourcesAsync( - ResourceType resourceType, - string propertyName, - string propertyValue, - ResourceKind resourceKind, - CancellationToken cancellationToken) + protected async Task> GetManySecondaryResourcesAsync(ResourceType resourceType, string propertyName, + string propertyValue, ResourceKind resourceKind, CancellationToken cancellationToken) { bool isIncluded = resourceKind == ResourceKind.Included; var (queryLayer, include) = _queryLayerComposer.ComposeFromConstraintsForNoSql(resourceType, propertyName, propertyValue, isIncluded); From 8c438b6152db2ab2e1c483f5d29b498fd85c82ca Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Tue, 9 Nov 2021 15:26:54 +0100 Subject: [PATCH 09/34] Enhance NoSqlQueryLayerComposer --- .../Queries/INoSqlQueryLayerComposer.cs | 28 +-- .../Queries/NoSqlQueryLayerComposer.cs | 231 +++++++++++++++--- 2 files changed, 206 insertions(+), 53 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs index 4f7f2c0e7c..cd4cba0809 100644 --- a/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/INoSqlQueryLayerComposer.cs @@ -22,20 +22,6 @@ public interface INoSqlQueryLayerComposer /// (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType); - /// - /// Composes a and an from the constraints specified by the request. Used for primary - /// resources. - /// - (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql(TId id, ResourceType primaryResourceType, - TopFieldSelection fieldSelection) - where TId : notnull; - - /// - /// Composes a with a filter expression in the form "equals(id,'{stringId}')". - /// - QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceType) - where TId : notnull; - /// /// Composes a from the constraints specified by the request and a filter expression in the form /// "equals({propertyName},'{propertyValue}')". Used for secondary or included resources. @@ -59,6 +45,20 @@ QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceTy (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType, string propertyName, string propertyValue, bool isIncluded); + /// + /// Composes a and an from the constraints specified by the request. Used for primary + /// resources. + /// + (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql(TId id, ResourceType primaryResourceType, + TopFieldSelection fieldSelection) + where TId : notnull; + + /// + /// Composes a with a filter expression in the form "equals(id,'{stringId}')". + /// + QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceType) + where TId : notnull; + /// /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete /// request. diff --git a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs index 4cc32af1b6..efbcba9fe4 100644 --- a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs @@ -1,12 +1,16 @@ +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; #pragma warning disable AV1551 // Method overload should call another overload #pragma warning disable AV2310 // Code block should not contain inline comment @@ -52,48 +56,23 @@ public NoSqlQueryLayerComposer(IEnumerable constraintP /// public FilterExpression? GetPrimaryFilterFromConstraintsForNoSql(ResourceType primaryResourceType) { - return GetPrimaryFilterFromConstraints(primaryResourceType); + return AssertFilterExpressionIsSimple(GetPrimaryFilterFromConstraints(primaryResourceType)); } /// public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType) { QueryLayer queryLayer = ComposeFromConstraints(requestResourceType); - IncludeExpression include = queryLayer.Include ?? IncludeExpression.Empty; - queryLayer.Include = IncludeExpression.Empty; - queryLayer.Projection = null; - - return (queryLayer, include); - } - - /// - public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql(TId id, ResourceType primaryResourceType, - TopFieldSelection fieldSelection) - where TId : notnull - { - QueryLayer queryLayer = ComposeForGetById(id, primaryResourceType, fieldSelection); - IncludeExpression include = queryLayer.Include ?? IncludeExpression.Empty; + IncludeExpression include = AssertIncludeExpressionIsSimple(queryLayer.Include); + queryLayer.Filter = AssertFilterExpressionIsSimple(queryLayer.Filter); queryLayer.Include = IncludeExpression.Empty; queryLayer.Projection = null; return (queryLayer, include); } - /// - public QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceType) - where TId : notnull - { - return new QueryLayer(primaryResourceType) - { - Filter = new ComparisonExpression(ComparisonOperator.Equals, - new ResourceFieldChainExpression(primaryResourceType.Fields.Single(field => field.Property.Name == nameof(IIdentifiable.Id))), - new LiteralConstantExpression(id.ToString()!)), - Include = IncludeExpression.Empty - }; - } - /// public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType, string propertyName, string propertyValue, bool isIncluded) @@ -104,27 +83,44 @@ public QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryRes ComposeSecondaryResourceFilter(requestResourceType, propertyName, propertyValue) }; + // @formatter:off + // Get the query expressions from the request. - ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); + ExpressionInScope[] constraints = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .ToArray(); bool IsQueryLayerConstraint(ExpressionInScope constraint) { - return constraint.Expression is not IncludeExpression && (!isIncluded || (constraint.Scope is not null && - constraint.Scope.Fields.Any(field => field.PublicName == requestResourceType.PublicName))); + return constraint.Expression is not IncludeExpression && (!isIncluded || IsResourceScoped(constraint)); + } + + bool IsResourceScoped(ExpressionInScope constraint) + { + return constraint.Scope is not null && + constraint.Scope.Fields.Any(field => field.PublicName == requestResourceType.PublicName); } - IEnumerable requestQueryExpressions = constraints.Where(IsQueryLayerConstraint).Select(constraint => constraint.Expression); + QueryExpression[] requestQueryExpressions = constraints + .Where(IsQueryLayerConstraint) + .Select(constraint => constraint.Expression) + .ToArray(); - // Combine the secondary resource filter and request query expressions and - // create the query layer from the combined query expressions. - QueryExpression[] queryExpressions = secondaryResourceFilterExpressions.Concat(requestQueryExpressions).ToArray(); + FilterExpression[] requestFilterExpressions = requestQueryExpressions + .OfType() + .Select(filterExpression => AssertFilterExpressionIsSimple(filterExpression)!) + .ToArray(); + + FilterExpression[] combinedFilterExpressions = secondaryResourceFilterExpressions + .Concat(requestFilterExpressions) + .ToArray(); var queryLayer = new QueryLayer(requestResourceType) { Include = IncludeExpression.Empty, - Filter = GetFilter(queryExpressions, requestResourceType), - Sort = GetSort(queryExpressions, requestResourceType), - Pagination = GetPagination(queryExpressions, requestResourceType) + Filter = GetFilter(combinedFilterExpressions, requestResourceType), + Sort = GetSort(requestQueryExpressions, requestResourceType), + Pagination = GetPagination(requestQueryExpressions, requestResourceType) }; // Retrieve the IncludeExpression from the constraints collection. @@ -133,7 +129,13 @@ bool IsQueryLayerConstraint(ExpressionInScope constraint) // into a single expression. IncludeExpression include = isIncluded ? IncludeExpression.Empty - : constraints.Select(constraint => constraint.Expression).OfType().DefaultIfEmpty(IncludeExpression.Empty).Single(); + : AssertIncludeExpressionIsSimple(constraints + .Select(constraint => constraint.Expression) + .OfType() + .DefaultIfEmpty(IncludeExpression.Empty) + .Single()); + + // @formatter:on return (queryLayer, include); } @@ -145,6 +147,35 @@ private static FilterExpression ComposeSecondaryResourceFilter(ResourceType reso new LiteralConstantExpression(properyValue)); } + /// + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql(TId id, ResourceType primaryResourceType, + TopFieldSelection fieldSelection) + where TId : notnull + { + QueryLayer queryLayer = ComposeForGetById(id, primaryResourceType, fieldSelection); + + IncludeExpression include = AssertIncludeExpressionIsSimple(queryLayer.Include); + + queryLayer.Filter = AssertFilterExpressionIsSimple(queryLayer.Filter); + queryLayer.Include = IncludeExpression.Empty; + queryLayer.Projection = null; + + return (queryLayer, include); + } + + /// + public QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryResourceType) + where TId : notnull + { + return new QueryLayer(primaryResourceType) + { + Filter = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(primaryResourceType.Fields.Single(field => field.Property.Name == nameof(IIdentifiable.Id))), + new LiteralConstantExpression(id.ToString()!)), + Include = IncludeExpression.Empty + }; + } + public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForUpdateForNoSql(TId id, ResourceType primaryResourceType) where TId : notnull { @@ -171,5 +202,127 @@ private static AttrAttribute GetIdAttribute(ResourceType resourceType) { return resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); } + + private static FilterExpression? AssertFilterExpressionIsSimple(FilterExpression? filterExpression) + { + if (filterExpression is null) + { + return filterExpression; + } + + var visitor = new FilterExpressionVisitor(); + + return visitor.Visit(filterExpression, null) + ? filterExpression + : throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Unsupported filter expression", + Detail = "Navigation of to-one or to-many relationships is not supported." + }); + } + + private static IncludeExpression AssertIncludeExpressionIsSimple(IncludeExpression? includeExpression) + { + if (includeExpression is null) + { + return IncludeExpression.Empty; + } + + return includeExpression.Elements.Any(element => element.Children.Any()) + ? throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Unsupported include expression", + Detail = "Multi-level include expressions are not supported." + }) + : includeExpression; + } + + private sealed class FilterExpressionVisitor : QueryExpressionVisitor + { + private bool _isSimpleFilterExpression = true; + + /// + public override bool DefaultVisit(QueryExpression expression, object? argument) + { + return _isSimpleFilterExpression; + } + + /// + public override bool VisitComparison(ComparisonExpression expression, object? argument) + { + return expression.Left.Accept(this, argument) && expression.Right.Accept(this, argument); + } + + /// + public override bool VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + { + _isSimpleFilterExpression &= expression.Fields.All(IsFieldSupported); + + return _isSimpleFilterExpression; + } + + private static bool IsFieldSupported(ResourceFieldAttribute field) + { + return field switch + { + AttrAttribute => true, + HasManyAttribute hasMany when HasOwnsManyAttribute(hasMany) => true, + _ => false + }; + } + + private static bool HasOwnsManyAttribute(ResourceFieldAttribute field) + { + return Attribute.GetCustomAttribute(field.Property, typeof(NoSqlOwnsManyAttribute)) is not null; + } + + /// + public override bool VisitLogical(LogicalExpression expression, object? argument) + { + return expression.Terms.All(term => term.Accept(this, argument)); + } + + /// + public override bool VisitNot(NotExpression expression, object? argument) + { + return expression.Child.Accept(this, argument); + } + + /// + public override bool VisitHas(HasExpression expression, object? argument) + { + return expression.TargetCollection.Accept(this, argument) && (expression.Filter is null || expression.Filter.Accept(this, argument)); + } + + /// + public override bool VisitSortElement(SortElementExpression expression, object? argument) + { + return expression.TargetAttribute is null || expression.TargetAttribute.Accept(this, argument); + } + + /// + public override bool VisitSort(SortExpression expression, object? argument) + { + return expression.Elements.All(element => element.Accept(this, argument)); + } + + /// + public override bool VisitCount(CountExpression expression, object? argument) + { + return expression.TargetCollection.Accept(this, argument); + } + + /// + public override bool VisitMatchText(MatchTextExpression expression, object? argument) + { + return expression.TargetAttribute.Accept(this, argument); + } + + /// + public override bool VisitAny(AnyExpression expression, object? argument) + { + return expression.TargetAttribute.Accept(this, argument); + } + } } } From 7396735d4a2064ab512f65f1fd0fc4422b454c2f Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Wed, 10 Nov 2021 09:33:55 +0100 Subject: [PATCH 10/34] Enhance FilterExpressionVisitor --- .../Queries/NoSqlQueryLayerComposer.cs | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs index efbcba9fe4..018c12a766 100644 --- a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs @@ -176,6 +176,7 @@ public QueryLayer ComposeForGetByIdForNoSql(TId id, ResourceType primaryRes }; } + /// public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForUpdateForNoSql(TId id, ResourceType primaryResourceType) where TId : notnull { @@ -250,7 +251,9 @@ public override bool DefaultVisit(QueryExpression expression, object? argument) /// public override bool VisitComparison(ComparisonExpression expression, object? argument) { - return expression.Left.Accept(this, argument) && expression.Right.Accept(this, argument); + _isSimpleFilterExpression &= expression.Left.Accept(this, argument) && expression.Right.Accept(this, argument); + + return _isSimpleFilterExpression; } /// @@ -279,49 +282,66 @@ private static bool HasOwnsManyAttribute(ResourceFieldAttribute field) /// public override bool VisitLogical(LogicalExpression expression, object? argument) { - return expression.Terms.All(term => term.Accept(this, argument)); + _isSimpleFilterExpression &= expression.Terms.All(term => term.Accept(this, argument)); + + return _isSimpleFilterExpression; } /// public override bool VisitNot(NotExpression expression, object? argument) { - return expression.Child.Accept(this, argument); + _isSimpleFilterExpression &= expression.Child.Accept(this, argument); + + return _isSimpleFilterExpression; } /// public override bool VisitHas(HasExpression expression, object? argument) { - return expression.TargetCollection.Accept(this, argument) && (expression.Filter is null || expression.Filter.Accept(this, argument)); + _isSimpleFilterExpression &= expression.TargetCollection.Accept(this, argument) && + (expression.Filter is null || expression.Filter.Accept(this, argument)); + + return _isSimpleFilterExpression; } /// public override bool VisitSortElement(SortElementExpression expression, object? argument) { - return expression.TargetAttribute is null || expression.TargetAttribute.Accept(this, argument); + _isSimpleFilterExpression &= expression.TargetAttribute is null || expression.TargetAttribute.Accept(this, argument); + + return _isSimpleFilterExpression; } /// public override bool VisitSort(SortExpression expression, object? argument) { - return expression.Elements.All(element => element.Accept(this, argument)); + _isSimpleFilterExpression &= expression.Elements.All(element => element.Accept(this, argument)); + + return _isSimpleFilterExpression; } /// public override bool VisitCount(CountExpression expression, object? argument) { - return expression.TargetCollection.Accept(this, argument); + _isSimpleFilterExpression &= expression.TargetCollection.Accept(this, argument); + + return _isSimpleFilterExpression; } /// public override bool VisitMatchText(MatchTextExpression expression, object? argument) { - return expression.TargetAttribute.Accept(this, argument); + _isSimpleFilterExpression &= expression.TargetAttribute.Accept(this, argument); + + return _isSimpleFilterExpression; } /// public override bool VisitAny(AnyExpression expression, object? argument) { - return expression.TargetAttribute.Accept(this, argument); + _isSimpleFilterExpression &= expression.TargetAttribute.Accept(this, argument); + + return _isSimpleFilterExpression; } } } From 7fc31f3f9794e55fc45fe0ee1855165c0664f1d0 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Wed, 10 Nov 2021 22:01:27 +0100 Subject: [PATCH 11/34] Add JsonApiDotNetCoreExample.Cosmos This commit adds an example for Cosmos DB, using the JsonApiDotNetCoreExample as the starting point and making changes required for Cosmos and to showcase Cosmos features (e.g., embedded / owned entities). --- JsonApiDotNetCore.sln | 39 +++++-- .../Controllers/NonJsonApiController.cs | 55 +++++++++ .../Controllers/OperationsController.cs | 18 +++ .../Controllers/PeopleController.cs | 18 +++ .../Controllers/TodoItemsController.cs | 18 +++ .../Data/AppDbContext.cs | 72 ++++++++++++ .../Definitions/TodoItemDefinition.cs | 59 ++++++++++ .../JsonApiDotNetCoreExample.Cosmos.csproj | 14 +++ .../Models/Person.cs | 61 ++++++++++ .../Models/Tag.cs | 19 +++ .../Models/TodoItem.cs | 108 ++++++++++++++++++ .../Models/TodoItemPriority.cs | 12 ++ .../Program.cs | 21 ++++ .../Properties/launchSettings.json | 30 +++++ .../Startup.cs | 97 ++++++++++++++++ .../appsettings.json | 16 +++ 16 files changed, 645 insertions(+), 12 deletions(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/NonJsonApiController.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/OperationsController.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/PeopleController.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/TodoItemsController.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Data/AppDbContext.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Definitions/TodoItemDefinition.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/JsonApiDotNetCoreExample.Cosmos.csproj create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Person.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Tag.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItemPriority.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Program.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Properties/launchSettings.json create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/Startup.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample.Cosmos/appsettings.json diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index e5e97193c2..79d97bb03f 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -44,6 +44,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextTests", "test EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestBuildingBlocks", "test\TestBuildingBlocks\TestBuildingBlocks.csproj", "{210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCoreExample.Cosmos", "src\Examples\JsonApiDotNetCoreExample.Cosmos\JsonApiDotNetCoreExample.Cosmos.csproj", "{74819978-5C4C-460F-B5DE-31D6B6B1289C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,6 +56,18 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.ActiveCfg = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.Build.0 = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.ActiveCfg = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.Build.0 = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.Build.0 = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.ActiveCfg = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.Build.0 = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.ActiveCfg = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.Build.0 = Release|Any CPU {CAF331F8-9255-4D72-A1A8-A54141E99F1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CAF331F8-9255-4D72-A1A8-A54141E99F1E}.Debug|Any CPU.Build.0 = Debug|Any CPU {CAF331F8-9255-4D72-A1A8-A54141E99F1E}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -162,18 +176,6 @@ Global {21D27239-138D-4604-8E49-DCBE41BCE4C8}.Release|x64.Build.0 = Release|Any CPU {21D27239-138D-4604-8E49-DCBE41BCE4C8}.Release|x86.ActiveCfg = Release|Any CPU {21D27239-138D-4604-8E49-DCBE41BCE4C8}.Release|x86.Build.0 = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.ActiveCfg = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.Build.0 = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.ActiveCfg = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.Build.0 = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.Build.0 = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.ActiveCfg = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.Build.0 = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.ActiveCfg = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.Build.0 = Release|Any CPU {6CAFDDBE-00AB-4784-801B-AB419C3C3A26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6CAFDDBE-00AB-4784-801B-AB419C3C3A26}.Debug|Any CPU.Build.0 = Debug|Any CPU {6CAFDDBE-00AB-4784-801B-AB419C3C3A26}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -210,6 +212,18 @@ Global {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x64.Build.0 = Release|Any CPU {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.ActiveCfg = Release|Any CPU {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.Build.0 = Release|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|x64.ActiveCfg = Debug|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|x64.Build.0 = Debug|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|x86.ActiveCfg = Debug|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|x86.Build.0 = Debug|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|Any CPU.Build.0 = Release|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|x64.ActiveCfg = Release|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|x64.Build.0 = Release|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|x86.ActiveCfg = Release|Any CPU + {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -228,6 +242,7 @@ Global {6CAFDDBE-00AB-4784-801B-AB419C3C3A26} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {74819978-5C4C-460F-B5DE-31D6B6B1289C} = {026FBC6C-AF76-4568-9B87-EC73457899FD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/NonJsonApiController.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/NonJsonApiController.cs new file mode 100644 index 0000000000..686578285f --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/NonJsonApiController.cs @@ -0,0 +1,55 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace JsonApiDotNetCoreExample.Cosmos.Controllers +{ + [Route("[controller]")] + public sealed class NonJsonApiController : ControllerBase + { + [HttpGet] + public IActionResult Get() + { + string[] result = + { + "Welcome!" + }; + + return Ok(result); + } + + [HttpPost] + public async Task PostAsync() + { + string name = await new StreamReader(Request.Body).ReadToEndAsync(); + + if (string.IsNullOrEmpty(name)) + { + return BadRequest("Please send your name."); + } + + string result = $"Hello, {name}"; + return Ok(result); + } + + [HttpPut] + public IActionResult Put([FromBody] string name) + { + string result = $"Hi, {name}"; + return Ok(result); + } + + [HttpPatch] + public IActionResult Patch(string name) + { + string result = $"Good day, {name}"; + return Ok(result); + } + + [HttpDelete] + public IActionResult Delete() + { + return Ok("Bye."); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/OperationsController.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/OperationsController.cs new file mode 100644 index 0000000000..34f1ac335d --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/OperationsController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Cosmos.Controllers +{ + public sealed class OperationsController : JsonApiOperationsController + { + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) + { + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/PeopleController.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/PeopleController.cs new file mode 100644 index 0000000000..53c320bfc1 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/PeopleController.cs @@ -0,0 +1,18 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Cosmos.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Cosmos.Controllers +{ + public sealed class PeopleController : JsonApiController + { + public PeopleController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/TodoItemsController.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/TodoItemsController.cs new file mode 100644 index 0000000000..7a554fe46b --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/TodoItemsController.cs @@ -0,0 +1,18 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Cosmos.Models; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Cosmos.Controllers +{ + public sealed class TodoItemsController : JsonApiController + { + public TodoItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Data/AppDbContext.cs new file mode 100644 index 0000000000..e91d7f087d --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Data/AppDbContext.cs @@ -0,0 +1,72 @@ +using System; +using JetBrains.Annotations; +using JsonApiDotNetCoreExample.Cosmos.Models; +using Microsoft.EntityFrameworkCore; + +#pragma warning disable IDE0058 // Expression value is never used +#pragma warning disable AV1706 // Identifier contains an abbreviation or is too short + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreExample.Cosmos.Data +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class AppDbContext : DbContext + { + public DbSet People => Set(); + + public DbSet TodoItems => Set(); + + public AppDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.HasDefaultContainer("PeopleAndTodoItems"); + builder.UsePropertyAccessMode(PropertyAccessMode.Property); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id) + .ValueGeneratedOnAdd(); + + entity.HasPartitionKey(e => e.PartitionKey); + }); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.Id) + .ValueGeneratedOnAdd(); + + entity.HasPartitionKey(e => e.PartitionKey); + + // @formatter:off + + entity.Property(e => e.Priority) + .HasConversion( + value => value.ToString(), + value => (TodoItemPriority)Enum.Parse(typeof(TodoItemPriority), value)); + + // @formatter:on + + entity.HasOne(todoItem => todoItem.Owner) + .WithMany(person => person.OwnedTodoItems) + .HasForeignKey(todoItem => todoItem.OwnerId) + .IsRequired(); + + entity.HasOne(todoItem => todoItem.Assignee) + .WithMany(person => person!.AssignedTodoItems) + .HasForeignKey(todoItem => todoItem.AssigneeId) + .IsRequired(false); + + entity.OwnsMany(todoItem => todoItem.Tags); + }); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Definitions/TodoItemDefinition.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Definitions/TodoItemDefinition.cs new file mode 100644 index 0000000000..9360c20533 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Definitions/TodoItemDefinition.cs @@ -0,0 +1,59 @@ +using System; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCoreExample.Cosmos.Models; +using Microsoft.AspNetCore.Authentication; + +#pragma warning disable AV2310 // Code block should not contain inline comment + +namespace JsonApiDotNetCoreExample.Cosmos.Definitions +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class TodoItemDefinition : JsonApiResourceDefinition + { + private readonly ISystemClock _systemClock; + + public TodoItemDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + : base(resourceGraph) + { + _systemClock = systemClock; + } + + /// + public override SortExpression OnApplySort(SortExpression? existingSort) + { + return existingSort ?? GetDefaultSortOrder(); + } + + private SortExpression GetDefaultSortOrder() + { + // Cosmos DB, we would have to define a composite index in order to support a composite sort expression. + // Therefore, we will only sort on a single property. + return CreateSortExpressionFromLambda(new PropertySortOrder + { + (todoItem => todoItem.Priority, ListSortDirection.Descending) + }); + } + + /// + public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource) + { + resource.CreatedAt = _systemClock.UtcNow; + } + else if (writeOperation == WriteOperationKind.UpdateResource) + { + resource.LastModifiedAt = _systemClock.UtcNow; + } + + return Task.CompletedTask; + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/JsonApiDotNetCoreExample.Cosmos.csproj b/src/Examples/JsonApiDotNetCoreExample.Cosmos/JsonApiDotNetCoreExample.Cosmos.csproj new file mode 100644 index 0000000000..3080b3edb4 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/JsonApiDotNetCoreExample.Cosmos.csproj @@ -0,0 +1,14 @@ + + + $(NetCoreAppVersion) + + + + + + + + + + + diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Person.cs new file mode 100644 index 0000000000..75e89f9923 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Person.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExample.Cosmos.Models +{ + [NoSqlResource] + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Person : Identifiable + { + /// + /// Gets or sets the partition key. + /// + /// + /// In this example, we are using a generic name for the partition key. We could have used any other sensible name such as PersonId, for example. In any + /// case, the property must exist in all classes the instances of which are to be stored in the same container. In our example project, both + /// and instances are stored in that container. A instance and all + /// instances owned by that person will thus be stored in the same logical partition. + /// + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + public string PartitionKey + { + get => Id.ToString(); + set => Id = Guid.Parse(value); + } + + /// + /// Gets or sets the optional first name. + /// + [Attr] + public string? FirstName { get; set; } + + /// + /// Gets or sets the required last name. + /// + [Attr] + public string LastName { get; set; } = null!; + + /// + /// Gets or sets the set of instances that are owned by this . + /// + /// + /// To enable the navigation of relationships, the name of the foreign key property must be specified for the navigation properties. + /// + [HasMany] + [NoSqlHasForeignKey(nameof(TodoItem.OwnerId))] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + + /// + /// Gets or sets the set of instances that are assigned to this . + /// + /// + /// To enable the navigation of relationships, the name of the foreign key property must be specified for the navigation properties. + /// + [HasMany] + [NoSqlHasForeignKey(nameof(TodoItem.AssigneeId))] + public ISet AssignedTodoItems { get; set; } = new HashSet(); + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Tag.cs new file mode 100644 index 0000000000..1340941316 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Tag.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExample.Cosmos.Models +{ + /// + /// Represents a tag that can be assigned to a . In this example project, tags are owned by the instances + /// and do not represent separate entities. + /// + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Tag + { + /// + /// Gets or sets the name of the . + /// + [MinLength(1)] + public string Name { get; set; } = null!; + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs new file mode 100644 index 0000000000..deaa9cb127 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCoreExample.Cosmos.Definitions; + +namespace JsonApiDotNetCoreExample.Cosmos.Models +{ + /// + /// Represents a to-do item that is owned by a person and that can be assigned to another person. + /// + [NoSqlResource] + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TodoItem : Identifiable + { + /// + /// Gets or sets the partition key. + /// + /// + /// In this example, we are using a generic name for the partition key. The property must exist in all classes the instances of which are to be stored in + /// the same container. + /// + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + public string PartitionKey + { + get => OwnerId.ToString(); + set => OwnerId = Guid.Parse(value); + } + + /// + /// Gets or sets the description of the to-do. + /// + [Attr] + public string Description { get; set; } = null!; + + /// + /// Gets or sets the priority of the to-do. + /// + [Attr] + public TodoItemPriority Priority { get; set; } = TodoItemPriority.Low; + + /// + /// Gets or sets the date and time at which the to-do was initially created. + /// + /// + /// This attribute will be set on the back end by the . + /// + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset CreatedAt { get; set; } + + /// + /// Gets or sets the date and time at which the to-do was last modified. + /// + /// + /// This attribute will be set on the back end by the . + /// + [Attr(PublicName = "modifiedAt", Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset? LastModifiedAt { get; set; } + + /// + /// Gets or sets the set of tags assigned to this . + /// + /// + /// Cosmos DB has the concept of owned entities, which we can make accessible as complex attributes. + /// + [Attr] + public ISet Tags { get; set; } = new HashSet(); + + /// + /// Gets or sets the of the . + /// + /// + /// With Cosmos DB, the foreign key must at least be accessible for filtering. Making it viewable is discouraged by the JSON:API specification. + /// + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + public Guid OwnerId { get; set; } + + /// + /// Gets or sets the of the . + /// + /// + /// With Cosmos DB, the foreign key must at least be accessible for filtering. Making it viewable is discouraged by the JSON:API specification. + /// + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + public Guid? AssigneeId { get; set; } + + /// + /// Gets or sets the owner of this . + /// + /// + /// To enable the navigation of relationships, the name of the foreign key property must be specified for the navigation properties. + /// + [HasOne] + [NoSqlHasForeignKey(nameof(OwnerId))] + public Person Owner { get; set; } = null!; + + /// + /// Gets or sets the optional assignee of this . + /// + /// + /// To enable the navigation of relationships, the name of the foreign key property must be specified for the navigation properties. + /// + [HasOne] + [NoSqlHasForeignKey(nameof(AssigneeId))] + public Person? Assignee { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItemPriority.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItemPriority.cs new file mode 100644 index 0000000000..17c245a27c --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItemPriority.cs @@ -0,0 +1,12 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExample.Cosmos.Models +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public enum TodoItemPriority + { + Low, + Medium, + High + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Program.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Program.cs new file mode 100644 index 0000000000..a781a60ef7 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Program.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace JsonApiDotNetCoreExample.Cosmos +{ + internal static class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + private static IHostBuilder CreateHostBuilder(string[] args) + { + return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Properties/launchSettings.json b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Properties/launchSettings.json new file mode 100644 index 0000000000..950d5415d6 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14140", + "sslPort": 44341 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "launchUrl": "api/v1/todoItems", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestrel": { + "commandName": "Project", + "launchBrowser": false, + "launchUrl": "api/v1/todoItems", + "applicationUrl": "https://localhost:44341;http://localhost:14141", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Startup.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Startup.cs new file mode 100644 index 0000000000..7b8cedc0db --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Startup.cs @@ -0,0 +1,97 @@ +using System; +using System.Text.Json.Serialization; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCoreExample.Cosmos.Data; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Cosmos +{ + public sealed class Startup + { + private readonly ICodeTimerSession _codeTimingSession; + private readonly string _connectionString; + + public Startup(IConfiguration configuration) + { + _codeTimingSession = new DefaultCodeTimerSession(); + CodeTimingSessionManager.Capture(_codeTimingSession); + + _connectionString = configuration["Data:DefaultConnection"]; + } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + using (CodeTimingSessionManager.Current.Measure("Configure other (startup)")) + { + services.AddSingleton(); + + services.AddDbContext(options => + { + options.UseCosmos(_connectionString, "TodoItemDB"); +#if DEBUG + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); +#endif + }); + + using (CodeTimingSessionManager.Current.Measure("Configure JSON:API (startup)")) + { + services.AddNoSqlResourceServices(); + + services.AddJsonApi(options => + { + options.Namespace = "api/v1"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.SerializerOptions.WriteIndented = true; + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; +#endif + }, discovery => discovery.AddCurrentAssembly()); + } + } + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment environment, ILoggerFactory loggerFactory) + { + ILogger logger = loggerFactory.CreateLogger(); + + using (CodeTimingSessionManager.Current.Measure("Initialize other (startup)")) + { + using (IServiceScope scope = app.ApplicationServices.CreateScope()) + { + var appDbContext = scope.ServiceProvider.GetRequiredService(); + appDbContext.Database.EnsureCreated(); + } + + app.UseRouting(); + + using (CodeTimingSessionManager.Current.Measure("Initialize JSON:API (startup)")) + { + app.UseJsonApi(); + } + + app.UseEndpoints(endpoints => endpoints.MapControllers()); + } + + if (CodeTimingSessionManager.IsEnabled) + { + string timingResults = CodeTimingSessionManager.Current.GetResults(); + logger.LogInformation($"Measurement results for application startup:{Environment.NewLine}{timingResults}"); + } + + _codeTimingSession.Dispose(); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/appsettings.json b/src/Examples/JsonApiDotNetCoreExample.Cosmos/appsettings.json new file mode 100644 index 0000000000..38ca89477d --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/appsettings.json @@ -0,0 +1,16 @@ +{ + "Data": { + "DefaultConnection": "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Hosting.Lifetime": "Warning", + "Microsoft.EntityFrameworkCore.Update": "Critical", + "Microsoft.EntityFrameworkCore.Database.Command": "Critical", + "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information", + "JsonApiDotNetCoreExample": "Information" + } + }, + "AllowedHosts": "*" +} From e65faceef62b53263567cdefc7f43aa7fa5744f6 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Wed, 10 Nov 2021 22:24:26 +0100 Subject: [PATCH 12/34] Fix comment Allegedly, Person.Id could not be resolved, which lead to an error on appveyor. This commit fixes that error by removing the cref. --- .../JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs index deaa9cb127..102b527fb9 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs @@ -68,7 +68,7 @@ public string PartitionKey public ISet Tags { get; set; } = new HashSet(); /// - /// Gets or sets the of the . + /// Gets or sets the person ID of the . /// /// /// With Cosmos DB, the foreign key must at least be accessible for filtering. Making it viewable is discouraged by the JSON:API specification. @@ -77,7 +77,7 @@ public string PartitionKey public Guid OwnerId { get; set; } /// - /// Gets or sets the of the . + /// Gets or sets the person ID of the . /// /// /// With Cosmos DB, the foreign key must at least be accessible for filtering. Making it viewable is discouraged by the JSON:API specification. From 755fac35f35a65505f69bbcfe05cc8119f2148d9 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Thu, 11 Nov 2021 00:03:39 +0100 Subject: [PATCH 13/34] Fix layout of appsettings.json --- src/Examples/JsonApiDotNetCoreExample.Cosmos/appsettings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/appsettings.json b/src/Examples/JsonApiDotNetCoreExample.Cosmos/appsettings.json index 38ca89477d..8fc2aef842 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample.Cosmos/appsettings.json @@ -1,6 +1,7 @@ { "Data": { - "DefaultConnection": "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" + "DefaultConnection": + "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" }, "Logging": { "LogLevel": { From 443aa98ec69efdb599965068fcd50b70690621b3 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Thu, 11 Nov 2021 17:29:00 +0100 Subject: [PATCH 14/34] Rename example to CosmosDbExample --- JsonApiDotNetCore.sln | 28 +++++++++---------- .../Controllers/NonJsonApiController.cs | 2 +- .../Controllers/OperationsController.cs | 2 +- .../Controllers/PeopleController.cs | 4 +-- .../Controllers/TodoItemsController.cs | 4 +-- .../CosmosDbExample.csproj} | 0 .../Data/AppDbContext.cs | 4 +-- .../Definitions/TodoItemDefinition.cs | 4 +-- .../Models/Person.cs | 2 +- .../Models/Tag.cs | 2 +- .../Models/TodoItem.cs | 4 +-- .../Models/TodoItemPriority.cs | 2 +- .../Program.cs | 2 +- .../Properties/launchSettings.json | 0 .../Startup.cs | 4 +-- .../appsettings.json | 0 16 files changed, 32 insertions(+), 32 deletions(-) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Controllers/NonJsonApiController.cs (95%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Controllers/OperationsController.cs (92%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Controllers/PeopleController.cs (83%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Controllers/TodoItemsController.cs (84%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos/JsonApiDotNetCoreExample.Cosmos.csproj => CosmosDbExample/CosmosDbExample.csproj} (100%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Data/AppDbContext.cs (95%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Definitions/TodoItemDefinition.cs (95%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Models/Person.cs (98%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Models/Tag.cs (92%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Models/TodoItem.cs (97%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Models/TodoItemPriority.cs (78%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Program.cs (92%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Properties/launchSettings.json (100%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/Startup.cs (97%) rename src/Examples/{JsonApiDotNetCoreExample.Cosmos => CosmosDbExample}/appsettings.json (100%) diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 79d97bb03f..d4a217d7e4 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -44,7 +44,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextTests", "test EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestBuildingBlocks", "test\TestBuildingBlocks\TestBuildingBlocks.csproj", "{210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCoreExample.Cosmos", "src\Examples\JsonApiDotNetCoreExample.Cosmos\JsonApiDotNetCoreExample.Cosmos.csproj", "{74819978-5C4C-460F-B5DE-31D6B6B1289C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CosmosDbExample", "src\Examples\CosmosDbExample\CosmosDbExample.csproj", "{3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -212,18 +212,18 @@ Global {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x64.Build.0 = Release|Any CPU {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.ActiveCfg = Release|Any CPU {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.Build.0 = Release|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|x64.ActiveCfg = Debug|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|x64.Build.0 = Debug|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|x86.ActiveCfg = Debug|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Debug|x86.Build.0 = Debug|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|Any CPU.Build.0 = Release|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|x64.ActiveCfg = Release|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|x64.Build.0 = Release|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|x86.ActiveCfg = Release|Any CPU - {74819978-5C4C-460F-B5DE-31D6B6B1289C}.Release|x86.Build.0 = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|x64.ActiveCfg = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|x64.Build.0 = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|x86.ActiveCfg = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Debug|x86.Build.0 = Debug|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|Any CPU.Build.0 = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|x64.ActiveCfg = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|x64.Build.0 = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|x86.ActiveCfg = Release|Any CPU + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -242,7 +242,7 @@ Global {6CAFDDBE-00AB-4784-801B-AB419C3C3A26} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} - {74819978-5C4C-460F-B5DE-31D6B6B1289C} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2} = {026FBC6C-AF76-4568-9B87-EC73457899FD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/NonJsonApiController.cs b/src/Examples/CosmosDbExample/Controllers/NonJsonApiController.cs similarity index 95% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/NonJsonApiController.cs rename to src/Examples/CosmosDbExample/Controllers/NonJsonApiController.cs index 686578285f..b4ac3c25c8 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/NonJsonApiController.cs +++ b/src/Examples/CosmosDbExample/Controllers/NonJsonApiController.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCoreExample.Cosmos.Controllers +namespace CosmosDbExample.Controllers { [Route("[controller]")] public sealed class NonJsonApiController : ControllerBase diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/OperationsController.cs b/src/Examples/CosmosDbExample/Controllers/OperationsController.cs similarity index 92% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/OperationsController.cs rename to src/Examples/CosmosDbExample/Controllers/OperationsController.cs index 34f1ac335d..c27456a963 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/OperationsController.cs +++ b/src/Examples/CosmosDbExample/Controllers/OperationsController.cs @@ -5,7 +5,7 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExample.Cosmos.Controllers +namespace CosmosDbExample.Controllers { public sealed class OperationsController : JsonApiOperationsController { diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/PeopleController.cs b/src/Examples/CosmosDbExample/Controllers/PeopleController.cs similarity index 83% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/PeopleController.cs rename to src/Examples/CosmosDbExample/Controllers/PeopleController.cs index 53c320bfc1..b5df1b86b1 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/PeopleController.cs +++ b/src/Examples/CosmosDbExample/Controllers/PeopleController.cs @@ -1,11 +1,11 @@ using System; +using CosmosDbExample.Models; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Cosmos.Models; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExample.Cosmos.Controllers +namespace CosmosDbExample.Controllers { public sealed class PeopleController : JsonApiController { diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/TodoItemsController.cs b/src/Examples/CosmosDbExample/Controllers/TodoItemsController.cs similarity index 84% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/TodoItemsController.cs rename to src/Examples/CosmosDbExample/Controllers/TodoItemsController.cs index 7a554fe46b..ec394fc22d 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Controllers/TodoItemsController.cs +++ b/src/Examples/CosmosDbExample/Controllers/TodoItemsController.cs @@ -1,11 +1,11 @@ using System; +using CosmosDbExample.Models; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Cosmos.Models; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExample.Cosmos.Controllers +namespace CosmosDbExample.Controllers { public sealed class TodoItemsController : JsonApiController { diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/JsonApiDotNetCoreExample.Cosmos.csproj b/src/Examples/CosmosDbExample/CosmosDbExample.csproj similarity index 100% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/JsonApiDotNetCoreExample.Cosmos.csproj rename to src/Examples/CosmosDbExample/CosmosDbExample.csproj diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Data/AppDbContext.cs b/src/Examples/CosmosDbExample/Data/AppDbContext.cs similarity index 95% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Data/AppDbContext.cs rename to src/Examples/CosmosDbExample/Data/AppDbContext.cs index e91d7f087d..bf51a53c0a 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Data/AppDbContext.cs +++ b/src/Examples/CosmosDbExample/Data/AppDbContext.cs @@ -1,6 +1,6 @@ using System; +using CosmosDbExample.Models; using JetBrains.Annotations; -using JsonApiDotNetCoreExample.Cosmos.Models; using Microsoft.EntityFrameworkCore; #pragma warning disable IDE0058 // Expression value is never used @@ -8,7 +8,7 @@ // @formatter:wrap_chained_method_calls chop_always -namespace JsonApiDotNetCoreExample.Cosmos.Data +namespace CosmosDbExample.Data { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class AppDbContext : DbContext diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Definitions/TodoItemDefinition.cs b/src/Examples/CosmosDbExample/Definitions/TodoItemDefinition.cs similarity index 95% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Definitions/TodoItemDefinition.cs rename to src/Examples/CosmosDbExample/Definitions/TodoItemDefinition.cs index 9360c20533..c6509b8f0d 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Definitions/TodoItemDefinition.cs +++ b/src/Examples/CosmosDbExample/Definitions/TodoItemDefinition.cs @@ -2,17 +2,17 @@ using System.ComponentModel; using System.Threading; using System.Threading.Tasks; +using CosmosDbExample.Models; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCoreExample.Cosmos.Models; using Microsoft.AspNetCore.Authentication; #pragma warning disable AV2310 // Code block should not contain inline comment -namespace JsonApiDotNetCoreExample.Cosmos.Definitions +namespace CosmosDbExample.Definitions { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class TodoItemDefinition : JsonApiResourceDefinition diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Person.cs b/src/Examples/CosmosDbExample/Models/Person.cs similarity index 98% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Person.cs rename to src/Examples/CosmosDbExample/Models/Person.cs index 75e89f9923..a5b14c1c56 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Person.cs +++ b/src/Examples/CosmosDbExample/Models/Person.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExample.Cosmos.Models +namespace CosmosDbExample.Models { [NoSqlResource] [UsedImplicitly(ImplicitUseTargetFlags.Members)] diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Tag.cs b/src/Examples/CosmosDbExample/Models/Tag.cs similarity index 92% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Tag.cs rename to src/Examples/CosmosDbExample/Models/Tag.cs index 1340941316..5ef395436a 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/Tag.cs +++ b/src/Examples/CosmosDbExample/Models/Tag.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; -namespace JsonApiDotNetCoreExample.Cosmos.Models +namespace CosmosDbExample.Models { /// /// Represents a tag that can be assigned to a . In this example project, tags are owned by the instances diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs b/src/Examples/CosmosDbExample/Models/TodoItem.cs similarity index 97% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs rename to src/Examples/CosmosDbExample/Models/TodoItem.cs index 102b527fb9..292a8abb0d 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItem.cs +++ b/src/Examples/CosmosDbExample/Models/TodoItem.cs @@ -1,11 +1,11 @@ using System; using System.Collections.Generic; +using CosmosDbExample.Definitions; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCoreExample.Cosmos.Definitions; -namespace JsonApiDotNetCoreExample.Cosmos.Models +namespace CosmosDbExample.Models { /// /// Represents a to-do item that is owned by a person and that can be assigned to another person. diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItemPriority.cs b/src/Examples/CosmosDbExample/Models/TodoItemPriority.cs similarity index 78% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItemPriority.cs rename to src/Examples/CosmosDbExample/Models/TodoItemPriority.cs index 17c245a27c..378d71f0fc 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Models/TodoItemPriority.cs +++ b/src/Examples/CosmosDbExample/Models/TodoItemPriority.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCoreExample.Cosmos.Models +namespace CosmosDbExample.Models { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public enum TodoItemPriority diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Program.cs b/src/Examples/CosmosDbExample/Program.cs similarity index 92% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Program.cs rename to src/Examples/CosmosDbExample/Program.cs index a781a60ef7..8d024d4f5a 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Program.cs +++ b/src/Examples/CosmosDbExample/Program.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; -namespace JsonApiDotNetCoreExample.Cosmos +namespace CosmosDbExample { internal static class Program { diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Properties/launchSettings.json b/src/Examples/CosmosDbExample/Properties/launchSettings.json similarity index 100% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Properties/launchSettings.json rename to src/Examples/CosmosDbExample/Properties/launchSettings.json diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Startup.cs b/src/Examples/CosmosDbExample/Startup.cs similarity index 97% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/Startup.cs rename to src/Examples/CosmosDbExample/Startup.cs index 7b8cedc0db..e15b683ada 100644 --- a/src/Examples/JsonApiDotNetCoreExample.Cosmos/Startup.cs +++ b/src/Examples/CosmosDbExample/Startup.cs @@ -1,8 +1,8 @@ using System; using System.Text.Json.Serialization; +using CosmosDbExample.Data; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; -using JsonApiDotNetCoreExample.Cosmos.Data; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -11,7 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExample.Cosmos +namespace CosmosDbExample { public sealed class Startup { diff --git a/src/Examples/JsonApiDotNetCoreExample.Cosmos/appsettings.json b/src/Examples/CosmosDbExample/appsettings.json similarity index 100% rename from src/Examples/JsonApiDotNetCoreExample.Cosmos/appsettings.json rename to src/Examples/CosmosDbExample/appsettings.json From 1ef4f50196902b10dad8f6c570f54c3a2f054be6 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Fri, 12 Nov 2021 22:29:22 +0100 Subject: [PATCH 15/34] Enhance NoSqlResourceService --- .../Queries/NoSqlQueryLayerComposer.cs | 18 +---- .../Annotations/NoSqlOwnsManyAttribute.cs | 31 --------- .../Services/NoSqlResourceService.cs | 69 +++++++++++-------- 3 files changed, 42 insertions(+), 76 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs diff --git a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs index 018c12a766..d57c4f5733 100644 --- a/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/NoSqlQueryLayerComposer.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -259,26 +258,11 @@ public override bool VisitComparison(ComparisonExpression expression, object? ar /// public override bool VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) { - _isSimpleFilterExpression &= expression.Fields.All(IsFieldSupported); + _isSimpleFilterExpression &= expression.Fields.All(field => field is AttrAttribute); return _isSimpleFilterExpression; } - private static bool IsFieldSupported(ResourceFieldAttribute field) - { - return field switch - { - AttrAttribute => true, - HasManyAttribute hasMany when HasOwnsManyAttribute(hasMany) => true, - _ => false - }; - } - - private static bool HasOwnsManyAttribute(ResourceFieldAttribute field) - { - return Attribute.GetCustomAttribute(field.Property, typeof(NoSqlOwnsManyAttribute)) is not null; - } - /// public override bool VisitLogical(LogicalExpression expression, object? argument) { diff --git a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs deleted file mode 100644 index fdebf28d3c..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/NoSqlOwnsManyAttribute.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Resources.Annotations -{ - /// - /// Used to provide additional information for a JSON:API relationship (https://jsonapi.org/format/#document-resource-object-relationships). - /// - /// - /// With Cosmos DB, for example, an entity can own one or many other entity types. In the example below, an Author entity owns many Article entities, - /// which is also reflected in the Entity Framework Core model. This means that all owned Article entities will be returned with the Author. To represent - /// Article entities as a to-many relationship, the [HasMany] and [NoSqlOwnsMany] annotations can be combined to indicate to the NoSQL resource service - /// that those Article resources do not have to be fetched. To include the Article resources into the response, the request will have to add an include - /// expression such as "?include=articles". Otherwise, the Article resources will not be returned. - /// - /// - /// - /// { - /// [HasMany] - /// [NoSqlOwnsMany] - /// public ICollection
Articles { get; set; } - /// } - /// ]]> - /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Property)] - public class NoSqlOwnsManyAttribute : Attribute - { - } -} diff --git a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs index 920fb6e3a4..75eafe28bc 100644 --- a/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs +++ b/src/JsonApiDotNetCore/Services/NoSqlResourceService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Net; using System.Reflection; @@ -185,10 +186,48 @@ public async Task GetAsync(TId id, CancellationToken cancellationToke } /// - public Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, + public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, CancellationToken cancellationToken) { - return _resourceService.AddToToManyRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); + RelationshipAttribute? relationship = _request.Relationship; + + if (relationship == null) + { + throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResourceType!.PublicName); + } + + if (!secondaryResourceIds.Any()) + { + return; + } + + ResourceType resourceType = relationship.RightType; + + var targetAttribute = new ResourceFieldChainExpression(resourceType.FindAttributeByPropertyName(nameof(IIdentifiable.Id))!); + + ImmutableHashSet idConstants = + secondaryResourceIds.Select(identifiable => new LiteralConstantExpression(identifiable.StringId!)).ToImmutableHashSet(); + + var queryLayer = new QueryLayer(resourceType) + { + Filter = idConstants.Count > 1 + ? new AnyExpression(targetAttribute, idConstants) + : new ComparisonExpression(ComparisonOperator.Equals, targetAttribute, idConstants.Single()) + }; + + IReadOnlyCollection secondaryResources = await _repositoryAccessor.GetAsync(resourceType, queryLayer, cancellationToken); + + ImmutableHashSet missingResources = secondaryResourceIds + .Where(requestResource => secondaryResources.All(resource => resource.StringId != requestResource.StringId)).ToImmutableHashSet(); + + if (missingResources.Any()) + { + IIdentifiable missingResource = missingResources.First(); + + throw new ResourceNotFoundException(missingResource.StringId!, resourceType.PublicName); + } + + await _resourceService.AddToToManyRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); } /// @@ -423,14 +462,6 @@ protected virtual async Task GetIncludedElementAsync(IReadOnlyCollection /// Gets the value of the named property. /// From fedad6824da200e41ae77eec71f2d31f97038bb8 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Fri, 12 Nov 2021 22:29:54 +0100 Subject: [PATCH 16/34] Implement integration tests --- JsonApiDotNetCore.sln | 15 + test/CosmosDbTests/CosmosDbFixture.cs | 135 +++++++ test/CosmosDbTests/CosmosDbTests.csproj | 22 ++ test/CosmosDbTests/PersonTests.cs | 484 ++++++++++++++++++++++++ test/CosmosDbTests/TodoItemTests.cs | 231 +++++++++++ test/CosmosDbTests/xunit.runner.json | 5 + 6 files changed, 892 insertions(+) create mode 100644 test/CosmosDbTests/CosmosDbFixture.cs create mode 100644 test/CosmosDbTests/CosmosDbTests.csproj create mode 100644 test/CosmosDbTests/PersonTests.cs create mode 100644 test/CosmosDbTests/TodoItemTests.cs create mode 100644 test/CosmosDbTests/xunit.runner.json diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index d4a217d7e4..93db0ea454 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -46,6 +46,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestBuildingBlocks", "test\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CosmosDbExample", "src\Examples\CosmosDbExample\CosmosDbExample.csproj", "{3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CosmosDbTests", "test\CosmosDbTests\CosmosDbTests.csproj", "{2E387408-8689-4335-A7C4-363DDB28E701}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -224,6 +226,18 @@ Global {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|x64.Build.0 = Release|Any CPU {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|x86.ActiveCfg = Release|Any CPU {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2}.Release|x86.Build.0 = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|x64.Build.0 = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Debug|x86.Build.0 = Debug|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|Any CPU.Build.0 = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|x64.ActiveCfg = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|x64.Build.0 = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|x86.ActiveCfg = Release|Any CPU + {2E387408-8689-4335-A7C4-363DDB28E701}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -243,6 +257,7 @@ Global {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {3AD5D1B4-8456-4639-98DB-F0D0EDAB2AD2} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {2E387408-8689-4335-A7C4-363DDB28E701} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/test/CosmosDbTests/CosmosDbFixture.cs b/test/CosmosDbTests/CosmosDbFixture.cs new file mode 100644 index 0000000000..76d8c1a492 --- /dev/null +++ b/test/CosmosDbTests/CosmosDbFixture.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CosmosDbExample; +using CosmosDbExample.Data; +using CosmosDbExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +#pragma warning disable AV1115 // Member or local function contains the word 'and', which suggests doing multiple things + +namespace CosmosDbTests +{ + /// + /// Test fixture that deletes and recreates the TodoItemDB database and PeopleAndTodoItems container before all of the test methods are run. It does not + /// delete the database nor the container at the end. + /// + [PublicAPI] + public class CosmosDbFixture : WebApplicationFactory, IAsyncLifetime + { + public Guid BonnieId { get; } = Guid.NewGuid(); + + public Guid ClydeId { get; } = Guid.NewGuid(); + + public Guid TodoItemOwnedByBonnieId { get; } = Guid.NewGuid(); + + public Guid TodoItemOwnedByClydeId { get; } = Guid.NewGuid(); + + public Guid TodoItemWithOwnerAndAssigneeId { get; } = Guid.NewGuid(); + + public async Task InitializeAsync() + { + await RunOnDatabaseAsync(async context => + { + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + + Person bonnie = new() + { + Id = BonnieId, + FirstName = "Bonnie", + LastName = "Parker" + }; + + Person clyde = new() + { + Id = ClydeId, + FirstName = "Clyde", + LastName = "Barrow" + }; + + TodoItem todoItemOwnedByBonnie = new() + { + Id = TodoItemOwnedByBonnieId, + Description = "Rob bank", + Priority = TodoItemPriority.High, + OwnerId = bonnie.Id, + Tags = new HashSet + { + new() + { + Name = "Job" + } + } + }; + + TodoItem todoItemOwnedByClyde = new() + { + Id = TodoItemOwnedByClydeId, + Description = "Wash car", + Priority = TodoItemPriority.Low, + OwnerId = clyde.Id + }; + + TodoItem todoItemWithOwnerAndAssignee = new() + { + Id = TodoItemWithOwnerAndAssigneeId, + Description = "Go shopping", + Priority = TodoItemPriority.Medium, + OwnerId = clyde.Id, + AssigneeId = bonnie.Id, + Tags = new HashSet + { + new() + { + Name = "Errands" + }, + new() + { + Name = "Groceries" + } + } + }; + + // @formatter:off + + context.People.AddRange(bonnie, clyde); + context.TodoItems.AddRange(todoItemOwnedByBonnie, todoItemOwnedByClyde, todoItemWithOwnerAndAssignee); + + // @formatter:on + + await context.SaveChangesAsync(); + }); + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } + + public async Task CreateEntityAsync(TEntity entity) + where TEntity : IIdentifiable + { + await RunOnDatabaseAsync(async context => + { + // ReSharper disable once MethodHasAsyncOverload + context.Add(entity); + await context.SaveChangesAsync(); + }); + + return entity; + } + + public async Task RunOnDatabaseAsync(Func asyncAction) + { + using IServiceScope scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await asyncAction(dbContext); + } + } +} diff --git a/test/CosmosDbTests/CosmosDbTests.csproj b/test/CosmosDbTests/CosmosDbTests.csproj new file mode 100644 index 0000000000..6f2fdff491 --- /dev/null +++ b/test/CosmosDbTests/CosmosDbTests.csproj @@ -0,0 +1,22 @@ + + + $(NetCoreAppVersion) + + + + + PreserveNewest + + + + + + + + + + + + + + diff --git a/test/CosmosDbTests/PersonTests.cs b/test/CosmosDbTests/PersonTests.cs new file mode 100644 index 0000000000..c5d14d0c4b --- /dev/null +++ b/test/CosmosDbTests/PersonTests.cs @@ -0,0 +1,484 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using CosmosDbExample.Models; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace CosmosDbTests +{ + public sealed class PersonTests : IntegrationTest, IClassFixture + { + private readonly CosmosDbFixture _fixture; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _fixture.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public PersonTests(CosmosDbFixture fixture) + { + _fixture = fixture; + } + + /// + /// GetAsync(CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + const string route = "/api/v1/people"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeNull(); + responseDocument.Data.ManyValue.Should().HaveCountGreaterOrEqualTo(2); + } + + /// + /// GetAsync(CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_get_primary_resources_with_filter() + { + // Arrange + const string fieldName = "firstName"; + const string fieldValue = "Clyde"; + string route = $"/api/v1/people?filter=equals({fieldName},'{fieldValue}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeNull().ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey(fieldName).With(value => value.Should().Be(fieldValue)); + } + + /// + /// GetAsync(CancellationToken cancellationToken) + /// + [Theory] + [InlineData("ownedTodoItems")] + [InlineData("assignedTodoItems")] + public async Task Can_get_primary_resources_including_secondary_resources(string relationshipName) + { + // Arrange + string route = $"/api/v1/people?include={relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeNull().And.NotBeEmpty(); + responseDocument.Included.Should().NotBeNull().And.NotBeEmpty(); + } + + /// + /// GetAsync(TId id, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_get_primary_resource_by_id() + { + // Arrange + string route = $"/api/v1/people/{_fixture.BonnieId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Bonnie")); + } + + /// + /// GetAsync(TId id, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("ownedTodoItems")] + [InlineData("assignedTodoItems")] + public async Task Can_get_primary_resource_by_id_including_secondary_resources(string relationshipName) + { + // Arrange + string route = $"/api/v1/people/{_fixture.BonnieId}?include={relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be("Bonnie")); + + responseDocument.Included.Should().NotBeNull().And.NotBeEmpty(); + } + + /// + /// GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("ownedTodoItems")] + [InlineData("assignedTodoItems")] + public async Task Can_get_secondary_resources_related_to_primary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/people/{_fixture.BonnieId}/{relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeNull().And.NotBeEmpty(); + + responseDocument.Included.Should().BeNull(); + } + + /// + /// GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("ownedTodoItems")] + [InlineData("assignedTodoItems")] + public async Task Can_get_relationships_of_primary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/people/{_fixture.BonnieId}/relationships/{relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldNotBeNull(); + + foreach (ResourceObject resourceObject in responseDocument.Data.ManyValue) + { + resourceObject.Type.ShouldNotBeNull(); + resourceObject.Id.ShouldNotBeNull(); + resourceObject.Attributes.Should().BeNull(); + resourceObject.Relationships.Should().BeNull(); + } + + responseDocument.Included.Should().BeNull(); + } + + /// + /// CreateAsync(TResource resource, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_create_primary_resource() + { + // Arrange + const string route = "/api/v1/people"; + + var person = new Person + { + FirstName = "Mad", + LastName = "Max" + }; + + var requestBody = new + { + data = new + { + type = "people", + attributes = new + { + firstName = person.FirstName, + lastName = person.LastName + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("firstName").With(value => value.Should().Be(person.FirstName)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("lastName").With(value => value.Should().Be(person.LastName)); + } + + /// + /// AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet{IIdentifiable} secondaryResourceIds, ...) + /// + [Fact] + public async Task Can_add_existing_secondary_resource_to_many_relationship() + { + // Arrange + TodoItem todoItem = await _fixture.CreateEntityAsync(new TodoItem + { + Id = Guid.NewGuid(), + Description = "Buy booze", + OwnerId = _fixture.ClydeId, + Tags = new HashSet + { + new() + { + Name = "Errand" + } + } + }); + + todoItem.AssigneeId.Should().BeNull(); + + Guid assigneeId = _fixture.BonnieId; + string route = $"/api/v1/people/{assigneeId}/relationships/assignedTodoItems"; + + var requestBody = new + { + Data = new[] + { + new + { + type = "todoItems", + id = todoItem.StringId + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _fixture.RunOnDatabaseAsync(async context => + { + TodoItem updatedTodoItem = await context.TodoItems.SingleAsync(item => item.Id == todoItem.Id); + updatedTodoItem.AssigneeId.Should().Be(assigneeId); + }); + } + + /// + /// AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet{IIdentifiable} secondaryResourceIds, ...) + /// + [Fact] + public async Task Cannot_add_non_existent_secondary_resource_to_many_relationship() + { + string route = $"/api/v1/people/{_fixture.BonnieId}/relationships/assignedTodoItems"; + + string nonExistentGuid = Guid.NewGuid().ToString(); + + var requestBody = new + { + Data = new[] + { + new + { + type = "todoItems", + Id = nonExistentGuid + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().NotBeNull().And.NotBeEmpty().And.HaveCount(1); + responseDocument.Errors![0].Detail.Should().Contain(nonExistentGuid); + } + + /// + /// UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_update_primary_resource() + { + // Arrange + const string unchangedFirstName = "Roger"; + const string originalLastName = "Rabbit"; + const string updatedLastName = "Rascal"; + + Person person = await _fixture.CreateEntityAsync(new Person + { + Id = Guid.NewGuid(), + FirstName = unchangedFirstName, + LastName = originalLastName + }); + + string route = $"/api/v1/people/{person.StringId}"; + + var requestBody = new + { + data = new + { + type = "people", + id = person.StringId, + attributes = new + { + lastName = updatedLastName + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _fixture.RunOnDatabaseAsync(async context => + { + Person updatedPerson = await context.People.SingleAsync(item => item.Id == person.Id); + + updatedPerson.FirstName.Should().Be(unchangedFirstName); + updatedPerson.LastName.Should().Be(updatedLastName); + }); + } + + /// + /// SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + /// + [Fact] + public Task Can_set_relationship_of_primary_resource() + { + // The test cannot be performed on people since there is no to-one relationship that can be set. + return Task.CompletedTask; + } + + /// + /// DeleteAsync(TId id, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_delete_primary_resource() + { + // Arrange + Person person = await _fixture.CreateEntityAsync(new Person + { + Id = Guid.NewGuid(), + FirstName = "Bugsy", + LastName = "Malone" + }); + + string route = $"/api/v1/people/{person.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _fixture.RunOnDatabaseAsync(async context => + { + Person? deletedPerson = await context.People.SingleOrDefaultAsync(item => item.Id == person.Id); + deletedPerson.Should().BeNull(); + }); + } + + /// + /// RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet{IIdentifiable} rightResourceIds, ...) + /// + [Fact] + public async Task Can_remove_existing_secondary_resource_from_to_many_relationship() + { + // Arrange + Guid assigneeId = _fixture.BonnieId; + + TodoItem todoItem = await _fixture.CreateEntityAsync(new TodoItem + { + Id = Guid.NewGuid(), + Description = "Buy gun", + OwnerId = _fixture.ClydeId, + AssigneeId = assigneeId, + Tags = new HashSet + { + new() + { + Name = "Errand" + } + } + }); + + string route = $"/api/v1/people/{assigneeId}/relationships/assignedTodoItems"; + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = todoItem.StringId + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _fixture.RunOnDatabaseAsync(async context => + { + TodoItem? updatedTodoItem = await context.TodoItems.SingleAsync(item => item.Id == todoItem.Id); + updatedTodoItem.AssigneeId.Should().BeNull(); + }); + } + + /// + /// RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet{IIdentifiable} rightResourceIds, ...) + /// + [Fact] + public async Task Removing_non_existent_secondary_resource_from_to_many_relationship_returns_no_content_status_code() + { + // Arrange + string route = $"/api/v1/people/{_fixture.BonnieId}/relationships/assignedTodoItems"; + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = Guid.NewGuid().ToString() + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + protected override HttpClient CreateClient() + { + return _fixture.CreateClient(); + } + } +} diff --git a/test/CosmosDbTests/TodoItemTests.cs b/test/CosmosDbTests/TodoItemTests.cs new file mode 100644 index 0000000000..6ef2921952 --- /dev/null +++ b/test/CosmosDbTests/TodoItemTests.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using CosmosDbExample.Models; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace CosmosDbTests +{ + public sealed class TodoItemTests : IntegrationTest, IClassFixture + { + private readonly CosmosDbFixture _fixture; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = _fixture.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public TodoItemTests(CosmosDbFixture fixture) + { + _fixture = fixture; + } + + /// + /// GetAsync(CancellationToken cancellationToken) + /// + [Theory] + [InlineData("owner")] + [InlineData("assignee")] + public async Task Can_get_primary_resources_including_secondary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/todoItems?include={relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().NotBeNull().And.NotBeEmpty(); + responseDocument.Included.Should().NotBeNull().And.NotBeEmpty(); + } + + /// + /// GetAsync(TId id, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("owner")] + [InlineData("assignee")] + public async Task Can_get_primary_resource_by_id_including_secondary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/todoItems/{_fixture.TodoItemWithOwnerAndAssigneeId}?include={relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + + responseDocument.Included.Should().NotBeNull().And.NotBeEmpty(); + } + + /// + /// GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("owner")] + [InlineData("assignee")] + public async Task Can_get_secondary_resource_related_to_primary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/todoItems/{_fixture.TodoItemWithOwnerAndAssigneeId}/{relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + + responseDocument.Included.Should().BeNull(); + } + + /// + /// GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + /// + [Theory] + [InlineData("owner")] + [InlineData("assignee")] + public async Task Can_get_relationships_of_primary_resource(string relationshipName) + { + // Arrange + string route = $"/api/v1/todoItems/{_fixture.TodoItemWithOwnerAndAssigneeId}/relationships/{relationshipName}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + responseDocument.Included.Should().BeNull(); + } + + /// + /// CreateAsync(TResource resource, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_create_primary_resource() + { + // Arrange + const string route = "/api/v1/todoItems"; + + var todoItem = new TodoItem + { + Description = "To-do created by API", + Tags = new HashSet + { + new() + { + Name = "First" + }, + new() + { + Name = "Second" + } + }, + OwnerId = _fixture.BonnieId + }; + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + description = todoItem.Description, + tags = todoItem.Tags.Select(tag => new + { + name = tag.Name + }) + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = todoItem.OwnerId.ToString() + } + } + } + } + }; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("description").With(value => value.Should().Be(todoItem.Description)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("tags").As>().Should().BeEquivalentTo(todoItem.Tags); + } + + /// + /// SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + /// + [Fact] + public async Task Can_set_relationship_of_primary_resource() + { + // Arrange + Guid todoItemId = _fixture.TodoItemOwnedByBonnieId; + Guid assigneeId = _fixture.ClydeId; + + string route = $"/api/v1/todoItems/{todoItemId}/relationships/assignee"; + + var requestBody = new + { + data = new + { + type = "people", + id = assigneeId.ToString() + } + }; + + // Act + (HttpResponseMessage httpResponse, Document _) = await ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + await _fixture.RunOnDatabaseAsync(async context => + { + TodoItem updatedTodoItem = await context.TodoItems.SingleAsync(item => item.Id == todoItemId); + updatedTodoItem.AssigneeId.Should().Be(assigneeId); + }); + } + + protected override HttpClient CreateClient() + { + return _fixture.CreateClient(); + } + } +} diff --git a/test/CosmosDbTests/xunit.runner.json b/test/CosmosDbTests/xunit.runner.json new file mode 100644 index 0000000000..8f5f10571b --- /dev/null +++ b/test/CosmosDbTests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "maxParallelThreads": 1 +} From 5c02d461982f8b369c413f836a899241ac621321 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Sat, 13 Nov 2021 19:41:40 +0100 Subject: [PATCH 17/34] Add build scripts for Cosmos DB --- Build.ps1 | 3 + ...zure-cosmos-emulator-linux-certificates.sh | 26 +++++++++ run-docker-azure-cosmos-emulator-linux.sh | 22 +++++++ shutdown-cosmos-db-emulator.ps1 | 22 +++++++ start-cosmos-db-emulator.ps1 | 58 +++++++++++++++++++ 5 files changed, 131 insertions(+) create mode 100644 install-azure-cosmos-emulator-linux-certificates.sh create mode 100644 run-docker-azure-cosmos-emulator-linux.sh create mode 100644 shutdown-cosmos-db-emulator.ps1 create mode 100644 start-cosmos-db-emulator.ps1 diff --git a/Build.ps1 b/Build.ps1 index 0cca69c095..561a65c559 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -99,6 +99,9 @@ CheckLastExitCode dotnet build -c Release CheckLastExitCode +.\start-cosmos-db-emulator.ps1 +CheckLastExitCode + RunInspectCode RunCleanupCode diff --git a/install-azure-cosmos-emulator-linux-certificates.sh b/install-azure-cosmos-emulator-linux-certificates.sh new file mode 100644 index 0000000000..66638d10b6 --- /dev/null +++ b/install-azure-cosmos-emulator-linux-certificates.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +result=1 +count=0 + +while [[ "$result" != "0" && "$count" < "5" ]]; do + echo "Trying to download certificate ..." + curl -k https://localhost:8081/_explorer/emulator.pem > ~/emulatorcert.crt 2> /dev/null + result=$? + let "count++" + + if [[ "$result" != "0" && "$count" < "5" ]] + then + echo "Could not download certificate. Waiting 10 seconds before trying again ..." + sleep 10 + fi +done + +if [[ $result -eq 0 ]] +then + echo "Updating CA certificates ..." + cp ~/emulatorcert.crt /usr/local/share/ca-certificates/ + update-ca-certificates +else + echo "Could not download CA certificate!" +fi diff --git a/run-docker-azure-cosmos-emulator-linux.sh b/run-docker-azure-cosmos-emulator-linux.sh new file mode 100644 index 0000000000..712f25ee69 --- /dev/null +++ b/run-docker-azure-cosmos-emulator-linux.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Determine the IP address of the local machine. +# This step is required when Direct mode setting is configured using Cosmos DB SDKs. +# ipaddr="`ifconfig | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -n 1`" + +# Pull the azure-cosmos-emulator Docker image for Linux from the registry. +docker pull mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator + +# Run the image, creating a container called "azure-cosmos-emulator-linux". +docker run \ + -p 8081:8081 \ + -p 10251:10251 \ + -p 10252:10252 \ + -p 10253:10253 \ + -p 10254:10254 \ + -m 3g \ + --cpus=2.0 \ + --name=azure-cosmos-emulator-linux \ + -e AZURE_COSMOS_EMULATOR_PARTITION_COUNT=3 \ + -e AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=true \ + mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator diff --git a/shutdown-cosmos-db-emulator.ps1 b/shutdown-cosmos-db-emulator.ps1 new file mode 100644 index 0000000000..007f7b7dbe --- /dev/null +++ b/shutdown-cosmos-db-emulator.ps1 @@ -0,0 +1,22 @@ +#Requires -Version 7 + +function ShutdownCosmosDbEmulator { + if ($PSVersionTable.Platform -eq "Unix") { + ShutdownCosmosDbEmulatorDockerContainer + } + else { + ShutdownCosmosDbEmulatorForWindows + } +} + +function ShutdownCosmosDbEmulatorDockerContainer { + Write-Host "Shutting down Cosmos DB Emulator Docker container ..." + docker stop azure-cosmos-emulator-linux +} + +function ShutdownCosmosDbEmulatorForWindows { + Write-Host "Shutting down Cosmos DB Emulator for Windows ..." + Start-Process -FilePath "C:\Program Files\Azure Cosmos DB Emulator\Microsoft.Azure.Cosmos.Emulator.exe" -ArgumentList "/Shutdown" +} + +ShutdownCosmosDbEmulator diff --git a/start-cosmos-db-emulator.ps1 b/start-cosmos-db-emulator.ps1 new file mode 100644 index 0000000000..0887490be5 --- /dev/null +++ b/start-cosmos-db-emulator.ps1 @@ -0,0 +1,58 @@ +#Requires -Version 7 + +function StartCosmosDbEmulator { + if ($PSVersionTable.Platform -eq "Unix") { + StartCosmosDbEmulatorDockerContainer + } + else { + StartCosmosDbEmulatorForWindows + } +} + +function StartCosmosDbEmulatorDockerContainer { + Write-Host "Starting Cosmos DB Emulator Docker container ..." + Start-Process nohup 'bash ./run-docker-azure-cosmos-emulator-linux.sh' + + Write-Host "Waiting for Cosmos DB Emulator Docker container to start up ..." + Start-Sleep -Seconds 30 + WaitUntilCosmosSqlApiEndpointIsReady + + Write-Host "Installing Cosmos DB Emulator certificates ..." + Start-Sleep -Seconds 30 + Start-Process -FilePath ".\install-azure-cosmos-emulator-linux-certificates.sh" +} + +function StartCosmosDbEmulatorForWindows { + Write-Host "Starting Cosmos DB Emulator for Windows ..." + Start-Process -FilePath "C:\Program Files\Azure Cosmos DB Emulator\Microsoft.Azure.Cosmos.Emulator.exe" -ArgumentList "/DisableRateLimiting /NoUI /NoExplorer" + + Write-Host "Waiting for Cosmos DB Emulator for Windows to start up ..." + Start-Sleep -Seconds 10 + WaitUntilCosmosSqlApiEndpointIsReady +} + +function WaitUntilCosmosSqlApiEndpointIsReady { + # See https://seddryck.wordpress.com/2020/01/05/running-automated-tests-with-the-azure-cosmos-db-emulator-on-appveyor/ + $attempt = 0; $max = 5 + + do { + $client = New-Object System.Net.Sockets.TcpClient([System.Net.Sockets.AddressFamily]::InterNetwork) + + try { + $client.Connect("localhost", 8081) + Write-Host "Cosmos SQL API endpoint listening. Connection successful." + } + catch { + $client.Close() + if($attempt -eq $max) { + throw "Cosmos SQL API endpoint is not listening. Aborting connection." + } else { + [int]$sleepTime = 5 * (++$attempt) + Write-Host "Cosmos SQL API endpoint is not yet listening. Retry after $sleepTime seconds..." + Start-Sleep -Seconds $sleepTime; + } + } + } while(!$client.Connected -and $attempt -lt $max) +} + +StartCosmosDbEmulator From 1bff1ccfd510d0794a58be4eef338f8819ac894c Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Sun, 14 Nov 2021 00:17:17 +0100 Subject: [PATCH 18/34] Start process with nohup Change the way the bash script is called to try to avoid a runtime error on appveyor. --- start-cosmos-db-emulator.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/start-cosmos-db-emulator.ps1 b/start-cosmos-db-emulator.ps1 index 0887490be5..e186c0636a 100644 --- a/start-cosmos-db-emulator.ps1 +++ b/start-cosmos-db-emulator.ps1 @@ -19,7 +19,7 @@ function StartCosmosDbEmulatorDockerContainer { Write-Host "Installing Cosmos DB Emulator certificates ..." Start-Sleep -Seconds 30 - Start-Process -FilePath ".\install-azure-cosmos-emulator-linux-certificates.sh" + Start-Process nohup 'bash ./install-azure-cosmos-emulator-linux-certificates.sh' } function StartCosmosDbEmulatorForWindows { From 88632962ce8df089a17d55d1552a6f56ecba3699 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 01:05:55 +0100 Subject: [PATCH 19/34] Fix build on Linux --- install-azure-cosmos-emulator-linux-certificates.sh | 10 +++++++--- run-docker-azure-cosmos-emulator-linux.sh | 3 ++- src/Examples/CosmosDbExample/Startup.cs | 7 ++++++- start-cosmos-db-emulator.ps1 | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/install-azure-cosmos-emulator-linux-certificates.sh b/install-azure-cosmos-emulator-linux-certificates.sh index 66638d10b6..7f94dced9a 100644 --- a/install-azure-cosmos-emulator-linux-certificates.sh +++ b/install-azure-cosmos-emulator-linux-certificates.sh @@ -1,11 +1,15 @@ #!/bin/bash +ipaddr="`ifconfig | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -n 1`" +certfile=~/emulatorcert.crt +echo "Certificate file: ${certfile}" + result=1 count=0 while [[ "$result" != "0" && "$count" < "5" ]]; do echo "Trying to download certificate ..." - curl -k https://localhost:8081/_explorer/emulator.pem > ~/emulatorcert.crt 2> /dev/null + curl -k https://$ipaddr:8081/_explorer/emulator.pem > $certfile result=$? let "count++" @@ -19,8 +23,8 @@ done if [[ $result -eq 0 ]] then echo "Updating CA certificates ..." - cp ~/emulatorcert.crt /usr/local/share/ca-certificates/ - update-ca-certificates + sudo cp $certfile /usr/local/share/ca-certificates + sudo update-ca-certificates else echo "Could not download CA certificate!" fi diff --git a/run-docker-azure-cosmos-emulator-linux.sh b/run-docker-azure-cosmos-emulator-linux.sh index 712f25ee69..0f30f3910c 100644 --- a/run-docker-azure-cosmos-emulator-linux.sh +++ b/run-docker-azure-cosmos-emulator-linux.sh @@ -2,7 +2,7 @@ # Determine the IP address of the local machine. # This step is required when Direct mode setting is configured using Cosmos DB SDKs. -# ipaddr="`ifconfig | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -n 1`" +ipaddr="`ifconfig | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -n 1`" # Pull the azure-cosmos-emulator Docker image for Linux from the registry. docker pull mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator @@ -19,4 +19,5 @@ docker run \ --name=azure-cosmos-emulator-linux \ -e AZURE_COSMOS_EMULATOR_PARTITION_COUNT=3 \ -e AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=true \ + -e AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE=$ipaddr \ mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator diff --git a/src/Examples/CosmosDbExample/Startup.cs b/src/Examples/CosmosDbExample/Startup.cs index e15b683ada..1b9b8d7b0c 100644 --- a/src/Examples/CosmosDbExample/Startup.cs +++ b/src/Examples/CosmosDbExample/Startup.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.Azure.Cosmos; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -35,7 +36,11 @@ public void ConfigureServices(IServiceCollection services) services.AddDbContext(options => { - options.UseCosmos(_connectionString, "TodoItemDB"); + options.UseCosmos(_connectionString, "TodoItemDB", builder => + { + // Required for connection to Azure Cosmos Emulator Docker container on Linux. + builder.ConnectionMode(ConnectionMode.Gateway); + }); #if DEBUG options.EnableSensitiveDataLogging(); options.EnableDetailedErrors(); diff --git a/start-cosmos-db-emulator.ps1 b/start-cosmos-db-emulator.ps1 index e186c0636a..0887490be5 100644 --- a/start-cosmos-db-emulator.ps1 +++ b/start-cosmos-db-emulator.ps1 @@ -19,7 +19,7 @@ function StartCosmosDbEmulatorDockerContainer { Write-Host "Installing Cosmos DB Emulator certificates ..." Start-Sleep -Seconds 30 - Start-Process nohup 'bash ./install-azure-cosmos-emulator-linux-certificates.sh' + Start-Process -FilePath ".\install-azure-cosmos-emulator-linux-certificates.sh" } function StartCosmosDbEmulatorForWindows { From 245fb67f062f0df94bd2a1b96fccb83efd85e8c5 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 01:41:41 +0100 Subject: [PATCH 20/34] Fix permission issue with Start-Process Use nohup to call a bash script to fix the "Permission denied" issue on appveyor. --- start-cosmos-db-emulator.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/start-cosmos-db-emulator.ps1 b/start-cosmos-db-emulator.ps1 index 0887490be5..e186c0636a 100644 --- a/start-cosmos-db-emulator.ps1 +++ b/start-cosmos-db-emulator.ps1 @@ -19,7 +19,7 @@ function StartCosmosDbEmulatorDockerContainer { Write-Host "Installing Cosmos DB Emulator certificates ..." Start-Sleep -Seconds 30 - Start-Process -FilePath ".\install-azure-cosmos-emulator-linux-certificates.sh" + Start-Process nohup 'bash ./install-azure-cosmos-emulator-linux-certificates.sh' } function StartCosmosDbEmulatorForWindows { From ab8dd8a881bd594025b0b94201e9e6d60ee6096c Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 11:15:43 +0100 Subject: [PATCH 21/34] Drill down into appveyor issue --- Build.ps1 | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Build.ps1 b/Build.ps1 index 561a65c559..6df7b7d83b 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -100,14 +100,15 @@ dotnet build -c Release CheckLastExitCode .\start-cosmos-db-emulator.ps1 +getcontent .\nohup.out CheckLastExitCode -RunInspectCode -RunCleanupCode +# RunInspectCode +# RunCleanupCode -dotnet test -c Release --no-build --collect:"XPlat Code Coverage" +dotnet test .\test\CosmosDbTests\CosmosDbTests.csproj -c Release --no-build --collect:"XPlat Code Coverage" CheckLastExitCode -ReportCodeCoverage +# ReportCodeCoverage -CreateNuGetPackage +# CreateNuGetPackage From 1e2fa474e024a3d9e0fd3b778d39e5d87cfeecb8 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 11:38:22 +0100 Subject: [PATCH 22/34] Enhance appveyor.yml and build scripts This commit makes a temporary change to the Build.ps1 script which needs to be reverted once the build is successful. --- Build.ps1 | 4 --- appveyor.yml | 29 +++++++++++++++ ...zure-cosmos-emulator-linux-certificates.sh | 1 + pull-docker-azure-cosmos-emulator-linux.sh | 4 +++ run-docker-azure-cosmos-emulator-linux.sh | 3 -- start-cosmos-db-emulator.ps1 | 35 +++++++++++-------- 6 files changed, 55 insertions(+), 21 deletions(-) create mode 100644 pull-docker-azure-cosmos-emulator-linux.sh diff --git a/Build.ps1 b/Build.ps1 index 6df7b7d83b..f6c2312cc6 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -99,10 +99,6 @@ CheckLastExitCode dotnet build -c Release CheckLastExitCode -.\start-cosmos-db-emulator.ps1 -getcontent .\nohup.out -CheckLastExitCode - # RunInspectCode # RunCleanupCode diff --git a/appveyor.yml b/appveyor.yml index 2b69ff39ca..131288c160 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -35,6 +35,7 @@ for: - image: Visual Studio 2019 services: - postgresql13 + - docker # REF: https://github.com/docascode/docfx-seed/blob/master/appveyor.yml before_build: - pwsh: | @@ -48,6 +49,34 @@ for: if ($lastexitcode -ne 0) { throw "docfx install failed with exit code $lastexitcode." } + - sh: | + # Pull Azure Cosmos Emulator Docker image + echo "Pulling Azure Cosmos Emulator Docker image for Linux ..." + ./pull-docker-azure-cosmos-emulator-linux.sh + + # Start Azure Cosmos Emulator container + echo "Running Azure Cosmos Emulator Docker container ..." + nohup ./run-docker-azure-cosmos-emulator-linux.sh & + + # Wait for Docker container being started in the background + echo "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." + sleep 60 + + # Print the background process output to see whether there are any errors + if [ -f "./nohup.out" ]; then + echo "--- BEGIN CONTENTS OF NOHUP.OUT ---" + cat ./nohup.out + echo "--- END CONTENTS OF NOHUP.OUT ---" + fi + + # Install SSL certificate to be able to access the emulator + echo "Installing Azure Cosmos Emulator SSL certificate ..." + sudo ./install-azure-cosmos-emulator-linux-certificates.sh + - pwsh: | + # Start Azure Cosmos Emulator on Windows + if ($isWindows) { + .\start-cosmos-db-emulator.ps1 + } after_build: - pwsh: | CD ./docs diff --git a/install-azure-cosmos-emulator-linux-certificates.sh b/install-azure-cosmos-emulator-linux-certificates.sh index 7f94dced9a..aaf505772e 100644 --- a/install-azure-cosmos-emulator-linux-certificates.sh +++ b/install-azure-cosmos-emulator-linux-certificates.sh @@ -27,4 +27,5 @@ then sudo update-ca-certificates else echo "Could not download CA certificate!" + false fi diff --git a/pull-docker-azure-cosmos-emulator-linux.sh b/pull-docker-azure-cosmos-emulator-linux.sh new file mode 100644 index 0000000000..77b7736702 --- /dev/null +++ b/pull-docker-azure-cosmos-emulator-linux.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# Pull the azure-cosmos-emulator Docker image for Linux from the registry. +docker pull mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator diff --git a/run-docker-azure-cosmos-emulator-linux.sh b/run-docker-azure-cosmos-emulator-linux.sh index 0f30f3910c..91e9c80bbe 100644 --- a/run-docker-azure-cosmos-emulator-linux.sh +++ b/run-docker-azure-cosmos-emulator-linux.sh @@ -4,9 +4,6 @@ # This step is required when Direct mode setting is configured using Cosmos DB SDKs. ipaddr="`ifconfig | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -n 1`" -# Pull the azure-cosmos-emulator Docker image for Linux from the registry. -docker pull mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator - # Run the image, creating a container called "azure-cosmos-emulator-linux". docker run \ -p 8081:8081 \ diff --git a/start-cosmos-db-emulator.ps1 b/start-cosmos-db-emulator.ps1 index e186c0636a..67f593bf33 100644 --- a/start-cosmos-db-emulator.ps1 +++ b/start-cosmos-db-emulator.ps1 @@ -2,32 +2,38 @@ function StartCosmosDbEmulator { if ($PSVersionTable.Platform -eq "Unix") { - StartCosmosDbEmulatorDockerContainer + StartCosmosDbEmulatorOnLinux } else { - StartCosmosDbEmulatorForWindows + StartCosmosDbEmulatorOnWindows } } -function StartCosmosDbEmulatorDockerContainer { - Write-Host "Starting Cosmos DB Emulator Docker container ..." - Start-Process nohup 'bash ./run-docker-azure-cosmos-emulator-linux.sh' +function StartCosmosDbEmulatorOnLinux { + Remove-Item .\nohup.* - Write-Host "Waiting for Cosmos DB Emulator Docker container to start up ..." - Start-Sleep -Seconds 30 - WaitUntilCosmosSqlApiEndpointIsReady + Write-Host "Running Azure Cosmos Emulator Docker container ..." + Start-Process nohup './run-docker-azure-cosmos-emulator-linux.sh' + Start-Sleep -Seconds 1 + + Write-Host "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." + Start-Sleep -Seconds 60 + + Write-Host "--- BEGIN CONTENTS OF NOHUP.OUT ---" + Get-Content .\nohup.out + Write-Host "--- END CONTENTS OF NOHUP.OUT ---" - Write-Host "Installing Cosmos DB Emulator certificates ..." - Start-Sleep -Seconds 30 - Start-Process nohup 'bash ./install-azure-cosmos-emulator-linux-certificates.sh' + Write-Host "Installing Azure Cosmos Emulator SSL certificate ..." + Start-Process bash './install-azure-cosmos-emulator-linux-certificates.sh' -Wait + Write-Host "Installed Azure Cosmos Emulator SSL certificate." } -function StartCosmosDbEmulatorForWindows { +function StartCosmosDbEmulatorOnWindows { Write-Host "Starting Cosmos DB Emulator for Windows ..." Start-Process -FilePath "C:\Program Files\Azure Cosmos DB Emulator\Microsoft.Azure.Cosmos.Emulator.exe" -ArgumentList "/DisableRateLimiting /NoUI /NoExplorer" - Write-Host "Waiting for Cosmos DB Emulator for Windows to start up ..." - Start-Sleep -Seconds 10 + Write-Host "Waiting for Azure Cosmos Emulator for Windows to start up ..." + Start-Sleep -Seconds 20 WaitUntilCosmosSqlApiEndpointIsReady } @@ -45,6 +51,7 @@ function WaitUntilCosmosSqlApiEndpointIsReady { catch { $client.Close() if($attempt -eq $max) { + Write-Host "Cosmos SQL API endpoint is not listening. Aborting connection." throw "Cosmos SQL API endpoint is not listening. Aborting connection." } else { [int]$sleepTime = 5 * (++$attempt) From f00121c477a465b1b4b601f8df72c2efe41eddc1 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 15:01:16 +0100 Subject: [PATCH 23/34] Change appveyor.yml It seems the before_build bash script was not executed. --- appveyor.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 131288c160..9b03827901 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -4,7 +4,7 @@ image: version: '{build}' -stack: postgresql 13.4 +stack: postgresql 13.4, docker environment: PGUSER: postgres @@ -38,17 +38,6 @@ for: - docker # REF: https://github.com/docascode/docfx-seed/blob/master/appveyor.yml before_build: - - pwsh: | - if (-Not $env:APPVEYOR_PULL_REQUEST_TITLE) { - # https://dotnet.github.io/docfx/tutorial/docfx_getting_started.html - git checkout $env:APPVEYOR_REPO_BRANCH -q - } - # Pinning to previous version, because zip of v2.58.8 (released 2d ago) is corrupt. - # Tracked at https://github.com/dotnet/docfx/issues/7689 - choco install docfx -y --version 2.58.5 - if ($lastexitcode -ne 0) { - throw "docfx install failed with exit code $lastexitcode." - } - sh: | # Pull Azure Cosmos Emulator Docker image echo "Pulling Azure Cosmos Emulator Docker image for Linux ..." @@ -77,6 +66,17 @@ for: if ($isWindows) { .\start-cosmos-db-emulator.ps1 } + - pwsh: | + if (-Not $env:APPVEYOR_PULL_REQUEST_TITLE) { + # https://dotnet.github.io/docfx/tutorial/docfx_getting_started.html + git checkout $env:APPVEYOR_REPO_BRANCH -q + } + # Pinning to previous version, because zip of v2.58.8 (released 2d ago) is corrupt. + # Tracked at https://github.com/dotnet/docfx/issues/7689 + choco install docfx -y --version 2.58.5 + if ($lastexitcode -ne 0) { + throw "docfx install failed with exit code $lastexitcode." + } after_build: - pwsh: | CD ./docs From 097bd4dae64a1217832758804e9e5d4636190249 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 15:26:51 +0100 Subject: [PATCH 24/34] Remove postgresql13 from Ubuntu list of servicesDefine appveyor.yml before_build script for Ubuntu --- appveyor.yml | 67 +++++++++++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 9b03827901..ad7f164f9a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -29,43 +29,14 @@ matrix: fast_finish: true for: -- +- # Visual Studio 2019 matrix: only: - image: Visual Studio 2019 services: - postgresql13 - - docker # REF: https://github.com/docascode/docfx-seed/blob/master/appveyor.yml before_build: - - sh: | - # Pull Azure Cosmos Emulator Docker image - echo "Pulling Azure Cosmos Emulator Docker image for Linux ..." - ./pull-docker-azure-cosmos-emulator-linux.sh - - # Start Azure Cosmos Emulator container - echo "Running Azure Cosmos Emulator Docker container ..." - nohup ./run-docker-azure-cosmos-emulator-linux.sh & - - # Wait for Docker container being started in the background - echo "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." - sleep 60 - - # Print the background process output to see whether there are any errors - if [ -f "./nohup.out" ]; then - echo "--- BEGIN CONTENTS OF NOHUP.OUT ---" - cat ./nohup.out - echo "--- END CONTENTS OF NOHUP.OUT ---" - fi - - # Install SSL certificate to be able to access the emulator - echo "Installing Azure Cosmos Emulator SSL certificate ..." - sudo ./install-azure-cosmos-emulator-linux-certificates.sh - - pwsh: | - # Start Azure Cosmos Emulator on Windows - if ($isWindows) { - .\start-cosmos-db-emulator.ps1 - } - pwsh: | if (-Not $env:APPVEYOR_PULL_REQUEST_TITLE) { # https://dotnet.github.io/docfx/tutorial/docfx_getting_started.html @@ -77,6 +48,11 @@ for: if ($lastexitcode -ne 0) { throw "docfx install failed with exit code $lastexitcode." } + - pwsh: | + # Start Azure Cosmos Emulator on Windows + if ($isWindows) { + .\start-cosmos-db-emulator.ps1 + } after_build: - pwsh: | CD ./docs @@ -124,6 +100,37 @@ for: on: branch: /release\/.+/ appveyor_repo_tag: true +- # Ubuntu + matrix: + only: + - image: Ubuntu + services: + - docker + before_build: + - sh: | + # Start Azure Cosmos Emulator on Linux + # Pull Azure Cosmos Emulator Docker image + echo "Pulling Azure Cosmos Emulator Docker image for Linux ..." + ./pull-docker-azure-cosmos-emulator-linux.sh + + # Start Azure Cosmos Emulator container + echo "Running Azure Cosmos Emulator Docker container ..." + nohup ./run-docker-azure-cosmos-emulator-linux.sh & + + # Wait for Docker container being started in the background + echo "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." + sleep 60 + + # Print the background process output to see whether there are any errors + if [ -f "./nohup.out" ]; then + echo "--- BEGIN CONTENTS OF NOHUP.OUT ---" + cat ./nohup.out + echo "--- END CONTENTS OF NOHUP.OUT ---" + fi + + # Install SSL certificate to be able to access the emulator + echo "Installing Azure Cosmos Emulator SSL certificate ..." + sudo ./install-azure-cosmos-emulator-linux-certificates.sh build_script: - pwsh: | From f1a096e90ed0bd52582378457db0ae8ad1431450 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 15:33:21 +0100 Subject: [PATCH 25/34] Use sudo to run bash scripts --- appveyor.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index ad7f164f9a..d287a65421 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -51,7 +51,7 @@ for: - pwsh: | # Start Azure Cosmos Emulator on Windows if ($isWindows) { - .\start-cosmos-db-emulator.ps1 + & .\start-cosmos-db-emulator.ps1 } after_build: - pwsh: | @@ -111,11 +111,11 @@ for: # Start Azure Cosmos Emulator on Linux # Pull Azure Cosmos Emulator Docker image echo "Pulling Azure Cosmos Emulator Docker image for Linux ..." - ./pull-docker-azure-cosmos-emulator-linux.sh + sudo ./pull-docker-azure-cosmos-emulator-linux.sh # Start Azure Cosmos Emulator container echo "Running Azure Cosmos Emulator Docker container ..." - nohup ./run-docker-azure-cosmos-emulator-linux.sh & + sudo nohup ./run-docker-azure-cosmos-emulator-linux.sh & # Wait for Docker container being started in the background echo "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." From 63bfa79d38f66feeed8042638fd124f508add48c Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 15:43:06 +0100 Subject: [PATCH 26/34] Try to fix before_build script This all worked on WSL. Why does it not work on appveyor? --- appveyor.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index d287a65421..fb4da8f1d1 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -111,11 +111,11 @@ for: # Start Azure Cosmos Emulator on Linux # Pull Azure Cosmos Emulator Docker image echo "Pulling Azure Cosmos Emulator Docker image for Linux ..." - sudo ./pull-docker-azure-cosmos-emulator-linux.sh + bash ./pull-docker-azure-cosmos-emulator-linux.sh # Start Azure Cosmos Emulator container echo "Running Azure Cosmos Emulator Docker container ..." - sudo nohup ./run-docker-azure-cosmos-emulator-linux.sh & + nohup bash ./run-docker-azure-cosmos-emulator-linux.sh & # Wait for Docker container being started in the background echo "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." @@ -130,7 +130,7 @@ for: # Install SSL certificate to be able to access the emulator echo "Installing Azure Cosmos Emulator SSL certificate ..." - sudo ./install-azure-cosmos-emulator-linux-certificates.sh + sudo bash ./install-azure-cosmos-emulator-linux-certificates.sh build_script: - pwsh: | From 606e2f79730e108c767e53cc44db9f6c202708c5 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 16:30:31 +0100 Subject: [PATCH 27/34] Try to build on Windows only --- appveyor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index fb4da8f1d1..43b6332ada 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,5 +1,5 @@ image: - - Ubuntu + # - Ubuntu - Visual Studio 2019 version: '{build}' From ddc822c87f60d3a202885f7bb4b33662cee11377 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 16:37:19 +0100 Subject: [PATCH 28/34] Launch Cosmos DB Emulator in build_script This is a temporary thing. Somehow the actual before_build script is not launched when there is only one image. --- appveyor.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/appveyor.yml b/appveyor.yml index 43b6332ada..3f6ccad722 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -145,6 +145,12 @@ build_script: psql --version } + # Start Azure Cosmos Emulator on Windows + Write-Output "Start Cosmos DB Emulator ..." + if ($isWindows) { + & .\start-cosmos-db-emulator.ps1 + } + .\Build.ps1 test: off From d5b8dcf3220cb5a0247850d85ca1cbb7948fd8fa Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 17:21:27 +0100 Subject: [PATCH 29/34] Next attempt --- appveyor.yml | 8 +------- src/Examples/CosmosDbExample/Startup.cs | 6 +----- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 3f6ccad722..fb4da8f1d1 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,5 +1,5 @@ image: - # - Ubuntu + - Ubuntu - Visual Studio 2019 version: '{build}' @@ -145,12 +145,6 @@ build_script: psql --version } - # Start Azure Cosmos Emulator on Windows - Write-Output "Start Cosmos DB Emulator ..." - if ($isWindows) { - & .\start-cosmos-db-emulator.ps1 - } - .\Build.ps1 test: off diff --git a/src/Examples/CosmosDbExample/Startup.cs b/src/Examples/CosmosDbExample/Startup.cs index 1b9b8d7b0c..87438e0125 100644 --- a/src/Examples/CosmosDbExample/Startup.cs +++ b/src/Examples/CosmosDbExample/Startup.cs @@ -36,11 +36,7 @@ public void ConfigureServices(IServiceCollection services) services.AddDbContext(options => { - options.UseCosmos(_connectionString, "TodoItemDB", builder => - { - // Required for connection to Azure Cosmos Emulator Docker container on Linux. - builder.ConnectionMode(ConnectionMode.Gateway); - }); + options.UseCosmos(_connectionString, "TodoItemDB"); #if DEBUG options.EnableSensitiveDataLogging(); options.EnableDetailedErrors(); From c21fad603c8e4dd8c2bad855259ad506b7923ce9 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 17:40:36 +0100 Subject: [PATCH 30/34] Disable CosmosDbTests on appveyor Ubuntu image --- Build.ps1 | 19 +++++++++++++------ appveyor.yml | 49 ++++++++++++++++++++++++++----------------------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/Build.ps1 b/Build.ps1 index f6c2312cc6..cfdfe4d7cc 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -99,12 +99,19 @@ CheckLastExitCode dotnet build -c Release CheckLastExitCode -# RunInspectCode -# RunCleanupCode +RunInspectCode +RunCleanupCode -dotnet test .\test\CosmosDbTests\CosmosDbTests.csproj -c Release --no-build --collect:"XPlat Code Coverage" -CheckLastExitCode +# Owing to issues with the Azure Cosmos Emulator for Linux on appveyor, do not run the CosmosDbTests on Linux +if ($isLinux) { + dotnet test --filter 'FullyQualifiedName!~CosmosDbTests' -c Release --no-build --collect:"XPlat Code Coverage" + CheckLastExitCode +} +else { + dotnet test -c Release --no-build --collect:"XPlat Code Coverage" + CheckLastExitCode +} -# ReportCodeCoverage +ReportCodeCoverage -# CreateNuGetPackage +CreateNuGetPackage diff --git a/appveyor.yml b/appveyor.yml index fb4da8f1d1..527ce5f9e6 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -108,29 +108,32 @@ for: - docker before_build: - sh: | - # Start Azure Cosmos Emulator on Linux - # Pull Azure Cosmos Emulator Docker image - echo "Pulling Azure Cosmos Emulator Docker image for Linux ..." - bash ./pull-docker-azure-cosmos-emulator-linux.sh - - # Start Azure Cosmos Emulator container - echo "Running Azure Cosmos Emulator Docker container ..." - nohup bash ./run-docker-azure-cosmos-emulator-linux.sh & - - # Wait for Docker container being started in the background - echo "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." - sleep 60 - - # Print the background process output to see whether there are any errors - if [ -f "./nohup.out" ]; then - echo "--- BEGIN CONTENTS OF NOHUP.OUT ---" - cat ./nohup.out - echo "--- END CONTENTS OF NOHUP.OUT ---" - fi - - # Install SSL certificate to be able to access the emulator - echo "Installing Azure Cosmos Emulator SSL certificate ..." - sudo bash ./install-azure-cosmos-emulator-linux-certificates.sh + # Do not run the Azure Cosmos Emulator for Linux for now + echo "Azure Cosmos Emulator for Linux is not being run at the moment" + + ## Start Azure Cosmos Emulator on Linux + ## Pull Azure Cosmos Emulator Docker image + #echo "Pulling Azure Cosmos Emulator Docker image for Linux ..." + #bash ./pull-docker-azure-cosmos-emulator-linux.sh + + ## Start Azure Cosmos Emulator container + #echo "Running Azure Cosmos Emulator Docker container ..." + #nohup bash ./run-docker-azure-cosmos-emulator-linux.sh & + + ## Wait for Docker container being started in the background + #echo "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." + #sleep 60 + + ## Print the background process output to see whether there are any errors + #if [ -f "./nohup.out" ]; then + # echo "--- BEGIN CONTENTS OF NOHUP.OUT ---" + # cat ./nohup.out + # echo "--- END CONTENTS OF NOHUP.OUT ---" + #fi + + ## Install SSL certificate to be able to access the emulator + #echo "Installing Azure Cosmos Emulator SSL certificate ..." + #sudo bash ./install-azure-cosmos-emulator-linux-certificates.sh build_script: - pwsh: | From 4c907f0c8978bd8c0538df58b86efa95a4c78271 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Mon, 15 Nov 2021 20:00:34 +0100 Subject: [PATCH 31/34] Remove using statement --- src/Examples/CosmosDbExample/Startup.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Examples/CosmosDbExample/Startup.cs b/src/Examples/CosmosDbExample/Startup.cs index 87438e0125..e15b683ada 100644 --- a/src/Examples/CosmosDbExample/Startup.cs +++ b/src/Examples/CosmosDbExample/Startup.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.Azure.Cosmos; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; From c94851a24950858eafe0aafca3c581d7ffcab63b Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Tue, 16 Nov 2021 16:55:29 +0100 Subject: [PATCH 32/34] Fix Docker config and enable tests on Linux --- Build.ps1 | 11 +---- appveyor.yml | 49 +++++++++---------- ...zure-cosmos-emulator-linux-certificates.sh | 3 +- run-docker-azure-cosmos-emulator-linux.sh | 13 +++-- 4 files changed, 34 insertions(+), 42 deletions(-) diff --git a/Build.ps1 b/Build.ps1 index cfdfe4d7cc..0cca69c095 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -102,15 +102,8 @@ CheckLastExitCode RunInspectCode RunCleanupCode -# Owing to issues with the Azure Cosmos Emulator for Linux on appveyor, do not run the CosmosDbTests on Linux -if ($isLinux) { - dotnet test --filter 'FullyQualifiedName!~CosmosDbTests' -c Release --no-build --collect:"XPlat Code Coverage" - CheckLastExitCode -} -else { - dotnet test -c Release --no-build --collect:"XPlat Code Coverage" - CheckLastExitCode -} +dotnet test -c Release --no-build --collect:"XPlat Code Coverage" +CheckLastExitCode ReportCodeCoverage diff --git a/appveyor.yml b/appveyor.yml index 527ce5f9e6..fb4da8f1d1 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -108,32 +108,29 @@ for: - docker before_build: - sh: | - # Do not run the Azure Cosmos Emulator for Linux for now - echo "Azure Cosmos Emulator for Linux is not being run at the moment" - - ## Start Azure Cosmos Emulator on Linux - ## Pull Azure Cosmos Emulator Docker image - #echo "Pulling Azure Cosmos Emulator Docker image for Linux ..." - #bash ./pull-docker-azure-cosmos-emulator-linux.sh - - ## Start Azure Cosmos Emulator container - #echo "Running Azure Cosmos Emulator Docker container ..." - #nohup bash ./run-docker-azure-cosmos-emulator-linux.sh & - - ## Wait for Docker container being started in the background - #echo "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." - #sleep 60 - - ## Print the background process output to see whether there are any errors - #if [ -f "./nohup.out" ]; then - # echo "--- BEGIN CONTENTS OF NOHUP.OUT ---" - # cat ./nohup.out - # echo "--- END CONTENTS OF NOHUP.OUT ---" - #fi - - ## Install SSL certificate to be able to access the emulator - #echo "Installing Azure Cosmos Emulator SSL certificate ..." - #sudo bash ./install-azure-cosmos-emulator-linux-certificates.sh + # Start Azure Cosmos Emulator on Linux + # Pull Azure Cosmos Emulator Docker image + echo "Pulling Azure Cosmos Emulator Docker image for Linux ..." + bash ./pull-docker-azure-cosmos-emulator-linux.sh + + # Start Azure Cosmos Emulator container + echo "Running Azure Cosmos Emulator Docker container ..." + nohup bash ./run-docker-azure-cosmos-emulator-linux.sh & + + # Wait for Docker container being started in the background + echo "Waiting 60 seconds before trying to download Azure Cosmos Emulator SSL certificate ..." + sleep 60 + + # Print the background process output to see whether there are any errors + if [ -f "./nohup.out" ]; then + echo "--- BEGIN CONTENTS OF NOHUP.OUT ---" + cat ./nohup.out + echo "--- END CONTENTS OF NOHUP.OUT ---" + fi + + # Install SSL certificate to be able to access the emulator + echo "Installing Azure Cosmos Emulator SSL certificate ..." + sudo bash ./install-azure-cosmos-emulator-linux-certificates.sh build_script: - pwsh: | diff --git a/install-azure-cosmos-emulator-linux-certificates.sh b/install-azure-cosmos-emulator-linux-certificates.sh index aaf505772e..8aee6e8cb2 100644 --- a/install-azure-cosmos-emulator-linux-certificates.sh +++ b/install-azure-cosmos-emulator-linux-certificates.sh @@ -1,6 +1,5 @@ #!/bin/bash -ipaddr="`ifconfig | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -n 1`" certfile=~/emulatorcert.crt echo "Certificate file: ${certfile}" @@ -9,7 +8,7 @@ count=0 while [[ "$result" != "0" && "$count" < "5" ]]; do echo "Trying to download certificate ..." - curl -k https://$ipaddr:8081/_explorer/emulator.pem > $certfile + curl -k https://localhost:8081/_explorer/emulator.pem > $certfile result=$? let "count++" diff --git a/run-docker-azure-cosmos-emulator-linux.sh b/run-docker-azure-cosmos-emulator-linux.sh index 91e9c80bbe..6593656787 100644 --- a/run-docker-azure-cosmos-emulator-linux.sh +++ b/run-docker-azure-cosmos-emulator-linux.sh @@ -1,10 +1,14 @@ #!/bin/bash -# Determine the IP address of the local machine. -# This step is required when Direct mode setting is configured using Cosmos DB SDKs. -ipaddr="`ifconfig | grep "inet " | grep -Fv 127.0.0.1 | awk '{print $2}' | head -n 1`" +# Run the Docker image that was previously pulled from the Docker repository, creating a container called +# "azure-cosmos-emulator-linux". Do not (!) set +# +# AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE=$ipaddr +# +# as suggested in Microsoft's documentation at https://docs.microsoft.com/en-us/azure/cosmos-db/linux-emulator. +# We would not be able to connect to the emulator at all on appveyor, regardless of the connection mode. +# To connect to the emulator, we must use Gateway mode. Direct mode will not work. -# Run the image, creating a container called "azure-cosmos-emulator-linux". docker run \ -p 8081:8081 \ -p 10251:10251 \ @@ -16,5 +20,4 @@ docker run \ --name=azure-cosmos-emulator-linux \ -e AZURE_COSMOS_EMULATOR_PARTITION_COUNT=3 \ -e AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE=true \ - -e AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE=$ipaddr \ mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator From 7add1c64ce1509fe424bb188f5c988a984c451e5 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Tue, 16 Nov 2021 18:11:25 +0100 Subject: [PATCH 33/34] Explicitly configure DbContextOptions Set ConnectionMode.Gateway and LimitToEndpoint = true. Set relatively short RequestTimeout (10 seconds) to fail fast. --- src/Examples/CosmosDbExample/Startup.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Examples/CosmosDbExample/Startup.cs b/src/Examples/CosmosDbExample/Startup.cs index e15b683ada..b33902f294 100644 --- a/src/Examples/CosmosDbExample/Startup.cs +++ b/src/Examples/CosmosDbExample/Startup.cs @@ -6,11 +6,14 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.Azure.Cosmos; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +#pragma warning disable AV2310 // Code block should not contain inline comment + namespace CosmosDbExample { public sealed class Startup @@ -35,7 +38,14 @@ public void ConfigureServices(IServiceCollection services) services.AddDbContext(options => { - options.UseCosmos(_connectionString, "TodoItemDB"); + options.UseCosmos(_connectionString, "TodoItemDB", builder => + { + builder.ConnectionMode(ConnectionMode.Gateway); + builder.LimitToEndpoint(); + + // Fail fast! + builder.RequestTimeout(TimeSpan.FromSeconds(10)); + }); #if DEBUG options.EnableSensitiveDataLogging(); options.EnableDetailedErrors(); From 063b04fd493435c9e6b5fa2f66eeef30badd7f77 Mon Sep 17 00:00:00 2001 From: ThomasBarnekow Date: Tue, 16 Nov 2021 20:33:27 +0100 Subject: [PATCH 34/34] Use default timeout and optimize build on Linux --- Build.ps1 | 6 ++++-- src/Examples/CosmosDbExample/Startup.cs | 3 --- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Build.ps1 b/Build.ps1 index 0cca69c095..def36a32c3 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -99,8 +99,10 @@ CheckLastExitCode dotnet build -c Release CheckLastExitCode -RunInspectCode -RunCleanupCode +if ($isWindows) { + RunInspectCode + RunCleanupCode +} dotnet test -c Release --no-build --collect:"XPlat Code Coverage" CheckLastExitCode diff --git a/src/Examples/CosmosDbExample/Startup.cs b/src/Examples/CosmosDbExample/Startup.cs index b33902f294..d674436c9a 100644 --- a/src/Examples/CosmosDbExample/Startup.cs +++ b/src/Examples/CosmosDbExample/Startup.cs @@ -42,9 +42,6 @@ public void ConfigureServices(IServiceCollection services) { builder.ConnectionMode(ConnectionMode.Gateway); builder.LimitToEndpoint(); - - // Fail fast! - builder.RequestTimeout(TimeSpan.FromSeconds(10)); }); #if DEBUG options.EnableSensitiveDataLogging();