Skip to content

Commit d555bb5

Browse files
author
Bart Koelman
authored
Refactorings (#1038)
Various refactorings * Renamed CollectionNotEmptyExpression to HasExpression * Renamed EqualsAnyOfExpression to AnyExpression * Changed SparseFieldSetExpression.Fields type from IReadOnlyCollection to IImmutableSet * Changed SparseFieldTableExpression.Table type from IReadOnlyDictionary to IImmutableDictionary and fixed equality comparison to discard order * Changed SortExpression.Elements type from IReadOnlyDictionary to IImmutableList * Changed ResourceFieldChainExpression.Fields type from IReadOnlyCollection to IImmutableList * Changed PaginationQueryStringValueExpression.Elements type from IReadOnlyCollection to IImmutableList * Changed AnyExpression.Constants type from IReadOnlyCollection to IImmutableSet * Changed LogicalExpression.Terms type from IReadOnlyCollection to IImmutableList * Changed IncludeExpression.Elements type and IncludeElementExpression.Children type from IReadOnlyCollection to IImmutableList * Add support for `IReadOnlySet<>` usage in resource models * Changed DisableQueryStringAttribute.ParameterNames type from IReadOnlyCollection to IReadOnlySet * Changed IResourceContextProvider.GetResourceContexts() to return a set instead of a list. Made ResourceContext sealed and immutable and implemented Equals/GetHashCode so it works with sets. * Use "endpoint" in RequestMethodNotAllowedException message * Renamed StandardQueryStringParameters to JsonApiQueryStringParameters * Replaced obsolete IQueryLayerComposer.GetResourceDefinitionAccessor() with injected service * Replaced obsolete IResourceFactory.GetResourceDefinitionAccessor() with injected service * Made TId in IResourceDefinition contravariant * Corrected terminology: relationships use left/right instead of primary/secondary * Removed obsolete IJsonApiRequest.BasePath * Removed EntityFrameworkCoreSupport (no longer needed for EF Core 5+) * Always set IJsonApiRequest.OperationKind from middleware * Renamed OperationKind to WriteOperationKind, exposed as IJsonApiRequest.WriteOperation * Add HTTP Method to trace logging
1 parent 3e982ec commit d555bb5

File tree

150 files changed

+1132
-1211
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

150 files changed

+1132
-1211
lines changed

benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,18 @@ public JsonApiDeserializerBenchmarks()
3838
IResourceGraph resourceGraph = _dependencyFactory.CreateResourceGraph(options);
3939

4040
var serviceContainer = new ServiceContainer();
41-
serviceContainer.AddService(typeof(IResourceDefinitionAccessor), new ResourceDefinitionAccessor(resourceGraph, serviceContainer));
41+
var resourceDefinitionAccessor = new ResourceDefinitionAccessor(resourceGraph, serviceContainer);
42+
43+
serviceContainer.AddService(typeof(IResourceDefinitionAccessor), resourceDefinitionAccessor);
4244
serviceContainer.AddService(typeof(IResourceDefinition<BenchmarkResource>), new JsonApiResourceDefinition<BenchmarkResource>(resourceGraph));
4345

4446
var targetedFields = new TargetedFields();
4547
var request = new JsonApiRequest();
4648
var resourceFactory = new ResourceFactory(serviceContainer);
4749
var httpContextAccessor = new HttpContextAccessor();
4850

49-
_jsonApiDeserializer = new RequestDeserializer(resourceGraph, resourceFactory, targetedFields, httpContextAccessor, request, options);
51+
_jsonApiDeserializer = new RequestDeserializer(resourceGraph, resourceFactory, targetedFields, httpContextAccessor, request, options,
52+
resourceDefinitionAccessor);
5053
}
5154

5255
[Benchmark]

docs/internals/queries.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Processing a request involves the following steps:
1818
- The readers also implement `IQueryConstraintProvider`, which exposes expressions through `ExpressionInScope` objects.
1919
- `QueryLayerComposer` (used from `JsonApiResourceService`) collects all query constraints.
2020
- It combines them with default options and `IResourceDefinition` overrides and composes a tree of `QueryLayer` objects.
21-
- It lifts the tree for nested endpoints like /blogs/1/articles and rewrites includes.
21+
- It lifts the tree for secondary endpoints like /blogs/1/articles and rewrites includes.
2222
- `JsonApiResourceService` contains no more usage of `IQueryable`.
2323
- `EntityFrameworkCoreRepository` delegates to `QueryableBuilder` to transform the `QueryLayer` tree into `IQueryable` expression trees.
2424
`QueryBuilder` depends on `QueryClauseBuilder` implementations that visit the tree nodes, transforming them to `System.Linq.Expression` equivalents.

docs/usage/extensibility/resource-definitions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,8 @@ public class EmployeeDefinition : JsonApiResourceDefinition<Employee>
202202
{
203203
}
204204

205-
public override IReadOnlyCollection<IncludeElementExpression> OnApplyIncludes(
206-
IReadOnlyCollection<IncludeElementExpression> existingIncludes)
205+
public override IImmutableList<IncludeElementExpression> OnApplyIncludes(
206+
IImmutableList<IncludeElementExpression> existingIncludes)
207207
{
208208
if (existingIncludes.Any(include =>
209209
include.Relationship.Property.Name == nameof(Employee.Manager)))

docs/usage/extensibility/services.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ public class TodoItemService : JsonApiResourceService<TodoItem>
1818
public TodoItemService(IResourceRepositoryAccessor repositoryAccessor,
1919
IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext,
2020
IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request,
21-
IResourceChangeTracker<TodoItem> resourceChangeTracker)
21+
IResourceChangeTracker<TodoItem> resourceChangeTracker,
22+
IResourceDefinitionAccessor resourceDefinitionAccessor)
2223
: base(repositoryAccessor, queryLayerComposer, paginationContext, options,
23-
loggerFactory, request, resourceChangeTracker)
24+
loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor)
2425
{
2526
_notificationService = notificationService;
2627
}

docs/usage/reading/including-relationships.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ which is equivalent to:
6262
GET /api/articles?include=author&include=author.livingAddress&include=author.livingAddress.country
6363
```
6464

65-
This can be used on nested endpoints too:
65+
This can be used on secondary endpoints too:
6666

6767
```http
6868
GET /api/blogs/1/articles?include=author.livingAddress.country

docs/usage/reading/pagination.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ Resources can be paginated. This request would fetch the second page of 10 artic
66
GET /articles?page[size]=10&page[number]=2 HTTP/1.1
77
```
88

9-
## Nesting
10-
11-
Pagination can be used on nested endpoints, such as:
9+
Pagination can be used on secondary endpoints, such as:
1210

1311
```http
1412
GET /blogs/1/articles?page[number]=2 HTTP/1.1

docs/usage/reading/sorting.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ GET /api/blogs?sort=count(articles) HTTP/1.1
3434

3535
This sorts the list of blogs by their number of articles.
3636

37-
## Nesting
37+
## Secondary endpoints
3838

39-
Sorting can be used on nested endpoints, such as:
39+
Sorting can be used on secondary endpoints, such as:
4040

4141
```http
4242
GET /api/blogs/1/articles?sort=caption HTTP/1.1

docs/usage/reading/sparse-fieldset-selection.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
As an alternative to returning all fields (attributes and relationships) from a resource, the `fields[]` query string parameter can be used to select a subset.
44
Put the resource type to apply the fieldset on between the brackets.
5-
This can be used on the resource being requested, as well as on nested endpoints and/or included resources.
5+
This can be used on primary and secondary endpoints. The selection is applied on both primary and included resources.
66

7-
Top-level example:
7+
Primary endpoint example:
88

99
```http
1010
GET /articles?fields[articles]=title,body,comments HTTP/1.1
1111
```
1212

13-
Nested endpoint example:
13+
Secondary endpoint example:
1414

1515
```http
1616
GET /api/blogs/1/articles?fields[articles]=title,body,comments HTTP/1.1

src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,13 @@ private SortExpression GetDefaultSortOrder()
3636
});
3737
}
3838

39-
public override Task OnWritingAsync(TodoItem resource, OperationKind operationKind, CancellationToken cancellationToken)
39+
public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken)
4040
{
41-
if (operationKind == OperationKind.CreateResource)
41+
if (writeOperation == WriteOperationKind.CreateResource)
4242
{
4343
resource.CreatedAt = _systemClock.UtcNow;
4444
}
45-
else if (operationKind == OperationKind.UpdateResource)
45+
else if (writeOperation == WriteOperationKind.UpdateResource)
4646
{
4747
resource.LastModifiedAt = _systemClock.UtcNow;
4848
}

src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ public sealed class DbContextARepository<TResource> : EntityFrameworkCoreReposit
1414
where TResource : class, IIdentifiable<int>
1515
{
1616
public DbContextARepository(ITargetedFields targetedFields, DbContextResolver<DbContextA> contextResolver, IResourceGraph resourceGraph,
17-
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
18-
: base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory)
17+
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
18+
IResourceDefinitionAccessor resourceDefinitionAccessor)
19+
: base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
1920
{
2021
}
2122
}

src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ public sealed class DbContextBRepository<TResource> : EntityFrameworkCoreReposit
1414
where TResource : class, IIdentifiable<int>
1515
{
1616
public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver<DbContextB> contextResolver, IResourceGraph resourceGraph,
17-
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
18-
: base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory)
17+
IResourceFactory resourceFactory, IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory,
18+
IResourceDefinitionAccessor resourceDefinitionAccessor)
19+
: base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor)
1920
{
2021
}
2122
}

src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,7 @@ public async Task<WorkItem> CreateAsync(WorkItem resource, CancellationToken can
7373
return workItems.Single();
7474
}
7575

76-
public Task AddToToManyRelationshipAsync(int primaryId, string relationshipName, ISet<IIdentifiable> secondaryResourceIds,
77-
CancellationToken cancellationToken)
76+
public Task AddToToManyRelationshipAsync(int leftId, string relationshipName, ISet<IIdentifiable> rightResourceIds, CancellationToken cancellationToken)
7877
{
7978
throw new NotImplementedException();
8079
}
@@ -84,7 +83,7 @@ public Task<WorkItem> UpdateAsync(int id, WorkItem resource, CancellationToken c
8483
throw new NotImplementedException();
8584
}
8685

87-
public Task SetRelationshipAsync(int primaryId, string relationshipName, object secondaryResourceIds, CancellationToken cancellationToken)
86+
public Task SetRelationshipAsync(int leftId, string relationshipName, object rightValue, CancellationToken cancellationToken)
8887
{
8988
throw new NotImplementedException();
9089
}
@@ -99,7 +98,7 @@ public async Task DeleteAsync(int id, CancellationToken cancellationToken)
9998
}, cancellationToken: cancellationToken)));
10099
}
101100

102-
public Task RemoveFromToManyRelationshipAsync(int primaryId, string relationshipName, ISet<IIdentifiable> secondaryResourceIds,
101+
public Task RemoveFromToManyRelationshipAsync(int leftId, string relationshipName, ISet<IIdentifiable> rightResourceIds,
103102
CancellationToken cancellationToken)
104103
{
105104
throw new NotImplementedException();

src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public void Validate(IEnumerable<OperationContainer> operations)
5656

5757
private void ValidateOperation(OperationContainer operation)
5858
{
59-
if (operation.Kind == OperationKind.CreateResource)
59+
if (operation.Kind == WriteOperationKind.CreateResource)
6060
{
6161
DeclareLocalId(operation.Resource);
6262
}
@@ -70,7 +70,7 @@ private void ValidateOperation(OperationContainer operation)
7070
AssertLocalIdIsAssigned(secondaryResource);
7171
}
7272

73-
if (operation.Kind == OperationKind.CreateResource)
73+
if (operation.Kind == WriteOperationKind.CreateResource)
7474
{
7575
AssignLocalId(operation);
7676
}

src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,37 +44,37 @@ protected virtual IOperationProcessor ResolveProcessor(OperationContainer operat
4444
return (IOperationProcessor)_serviceProvider.GetRequiredService(processorType);
4545
}
4646

47-
private static Type GetProcessorInterface(OperationKind kind)
47+
private static Type GetProcessorInterface(WriteOperationKind writeOperation)
4848
{
49-
switch (kind)
49+
switch (writeOperation)
5050
{
51-
case OperationKind.CreateResource:
51+
case WriteOperationKind.CreateResource:
5252
{
5353
return typeof(ICreateProcessor<,>);
5454
}
55-
case OperationKind.UpdateResource:
55+
case WriteOperationKind.UpdateResource:
5656
{
5757
return typeof(IUpdateProcessor<,>);
5858
}
59-
case OperationKind.DeleteResource:
59+
case WriteOperationKind.DeleteResource:
6060
{
6161
return typeof(IDeleteProcessor<,>);
6262
}
63-
case OperationKind.SetRelationship:
63+
case WriteOperationKind.SetRelationship:
6464
{
6565
return typeof(ISetRelationshipProcessor<,>);
6666
}
67-
case OperationKind.AddToRelationship:
67+
case WriteOperationKind.AddToRelationship:
6868
{
6969
return typeof(IAddToRelationshipProcessor<,>);
7070
}
71-
case OperationKind.RemoveFromRelationship:
71+
case WriteOperationKind.RemoveFromRelationship:
7272
{
7373
return typeof(IRemoveFromRelationshipProcessor<,>);
7474
}
7575
default:
7676
{
77-
throw new NotSupportedException($"Unknown operation kind '{kind}'.");
77+
throw new NotSupportedException($"Unknown write operation kind '{writeOperation}'.");
7878
}
7979
}
8080
}

src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ protected virtual async Task<OperationContainer> ProcessOperationAsync(Operation
118118

119119
protected void TrackLocalIdsForOperation(OperationContainer operation)
120120
{
121-
if (operation.Kind == OperationKind.CreateResource)
121+
if (operation.Kind == WriteOperationKind.CreateResource)
122122
{
123123
DeclareLocalId(operation.Resource);
124124
}

src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ public virtual async Task<OperationContainer> ProcessAsync(OperationContainer op
2626
{
2727
ArgumentGuard.NotNull(operation, nameof(operation));
2828

29-
var primaryId = (TId)operation.Resource.GetTypedId();
30-
ISet<IIdentifiable> secondaryResourceIds = operation.GetSecondaryResources();
29+
var leftId = (TId)operation.Resource.GetTypedId();
30+
ISet<IIdentifiable> rightResourceIds = operation.GetSecondaryResources();
3131

32-
await _service.AddToToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken);
32+
await _service.AddToToManyRelationshipAsync(leftId, operation.Request.Relationship.PublicName, rightResourceIds, cancellationToken);
3333

3434
return null;
3535
}

src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ public virtual async Task<OperationContainer> ProcessAsync(OperationContainer op
2626
{
2727
ArgumentGuard.NotNull(operation, nameof(operation));
2828

29-
var primaryId = (TId)operation.Resource.GetTypedId();
30-
ISet<IIdentifiable> secondaryResourceIds = operation.GetSecondaryResources();
29+
var leftId = (TId)operation.Resource.GetTypedId();
30+
ISet<IIdentifiable> rightResourceIds = operation.GetSecondaryResources();
3131

32-
await _service.RemoveFromToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken);
32+
await _service.RemoveFromToManyRelationshipAsync(leftId, operation.Request.Relationship.PublicName, rightResourceIds, cancellationToken);
3333

3434
return null;
3535
}

src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ public virtual async Task<OperationContainer> ProcessAsync(OperationContainer op
2929
{
3030
ArgumentGuard.NotNull(operation, nameof(operation));
3131

32-
var primaryId = (TId)operation.Resource.GetTypedId();
32+
var leftId = (TId)operation.Resource.GetTypedId();
3333
object rightValue = GetRelationshipRightValue(operation);
3434

35-
await _service.SetRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, rightValue, cancellationToken);
35+
await _service.SetRelationshipAsync(leftId, operation.Request.Relationship.PublicName, rightValue, cancellationToken);
3636

3737
return null;
3838
}

src/JsonApiDotNetCore/CollectionConverter.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ namespace JsonApiDotNetCore
88
{
99
internal sealed class CollectionConverter
1010
{
11-
private static readonly Type[] HashSetCompatibleCollectionTypes =
11+
private static readonly ISet<Type> HashSetCompatibleCollectionTypes = new HashSet<Type>
1212
{
1313
typeof(HashSet<>),
14-
typeof(ICollection<>),
1514
typeof(ISet<>),
16-
typeof(IEnumerable<>),
17-
typeof(IReadOnlyCollection<>)
15+
typeof(IReadOnlySet<>),
16+
typeof(ICollection<>),
17+
typeof(IReadOnlyCollection<>),
18+
typeof(IEnumerable<>)
1819
};
1920

2021
/// <summary>
@@ -45,19 +46,18 @@ public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType
4546
/// <summary>
4647
/// Returns a compatible collection type that can be instantiated, for example IList{Article} -> List{Article} or ISet{Article} -> HashSet{Article}
4748
/// </summary>
48-
public Type ToConcreteCollectionType(Type collectionType)
49+
private Type ToConcreteCollectionType(Type collectionType)
4950
{
5051
if (collectionType.IsInterface && collectionType.IsGenericType)
5152
{
52-
Type genericTypeDefinition = collectionType.GetGenericTypeDefinition();
53+
Type openCollectionType = collectionType.GetGenericTypeDefinition();
5354

54-
if (genericTypeDefinition == typeof(ICollection<>) || genericTypeDefinition == typeof(ISet<>) ||
55-
genericTypeDefinition == typeof(IEnumerable<>) || genericTypeDefinition == typeof(IReadOnlyCollection<>))
55+
if (HashSetCompatibleCollectionTypes.Contains(openCollectionType))
5656
{
5757
return typeof(HashSet<>).MakeGenericType(collectionType.GenericTypeArguments[0]);
5858
}
5959

60-
if (genericTypeDefinition == typeof(IList<>) || genericTypeDefinition == typeof(IReadOnlyList<>))
60+
if (openCollectionType == typeof(IList<>) || openCollectionType == typeof(IReadOnlyList<>))
6161
{
6262
return typeof(List<>).MakeGenericType(collectionType.GenericTypeArguments[0]);
6363
}

src/JsonApiDotNetCore/CollectionExtensions.cs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public static bool IsNullOrEmpty<T>(this IEnumerable<T> source)
1919
return !source.Any();
2020
}
2121

22-
public static int FindIndex<T>(this IList<T> source, Predicate<T> match)
22+
public static int FindIndex<T>(this IReadOnlyList<T> source, Predicate<T> match)
2323
{
2424
ArgumentGuard.NotNull(source, nameof(source));
2525
ArgumentGuard.NotNull(match, nameof(match));
@@ -34,5 +34,41 @@ public static int FindIndex<T>(this IList<T> source, Predicate<T> match)
3434

3535
return -1;
3636
}
37+
38+
public static bool DictionaryEqual<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> first, IReadOnlyDictionary<TKey, TValue> second,
39+
IEqualityComparer<TValue> valueComparer = null)
40+
{
41+
if (first == second)
42+
{
43+
return true;
44+
}
45+
46+
if (first == null || second == null)
47+
{
48+
return false;
49+
}
50+
51+
if (first.Count != second.Count)
52+
{
53+
return false;
54+
}
55+
56+
IEqualityComparer<TValue> effectiveValueComparer = valueComparer ?? EqualityComparer<TValue>.Default;
57+
58+
foreach ((TKey firstKey, TValue firstValue) in first)
59+
{
60+
if (!second.TryGetValue(firstKey, out TValue secondValue))
61+
{
62+
return false;
63+
}
64+
65+
if (!effectiveValueComparer.Equals(firstValue, secondValue))
66+
{
67+
return false;
68+
}
69+
}
70+
71+
return true;
72+
}
3773
}
3874
}

0 commit comments

Comments
 (0)