Skip to content

Commit 887a153

Browse files
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)
1 parent b88d39e commit 887a153

File tree

7 files changed

+1121
-0
lines changed

7 files changed

+1121
-0
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System;
2+
using System.Diagnostics.CodeAnalysis;
3+
using System.Linq;
4+
using System.Reflection;
5+
using JetBrains.Annotations;
6+
using JsonApiDotNetCore.Queries;
7+
using JsonApiDotNetCore.Resources;
8+
using JsonApiDotNetCore.Resources.Annotations;
9+
using JsonApiDotNetCore.Services;
10+
using Microsoft.Extensions.DependencyInjection;
11+
12+
namespace JsonApiDotNetCore.Configuration
13+
{
14+
/// <summary>
15+
/// Provides extension methods for the registration of services and other injectables
16+
/// with the service container.
17+
/// </summary>
18+
[PublicAPI]
19+
public static class NoSqlServiceCollectionExtensions
20+
{
21+
/// <summary>
22+
/// For each resource annotated with the <see cref="NoSqlResourceAttribute" />, adds a
23+
/// scoped service with a service type of <see cref="IResourceService{TResource, TId}" />
24+
/// and an implementation type of <see cref="NoSqlResourceService{TResource,TId}" />.
25+
/// </summary>
26+
/// <param name="services">The <see cref="IServiceCollection" />.</param>
27+
/// <returns>The <see cref="IServiceCollection" />.</returns>
28+
public static IServiceCollection AddNoSqlResourceServices(this IServiceCollection services)
29+
{
30+
return services.AddNoSqlResourceServices(Assembly.GetCallingAssembly());
31+
}
32+
33+
/// <summary>
34+
/// For each resource annotated with the <see cref="NoSqlResourceAttribute" />, adds a
35+
/// scoped service with a service type of <see cref="IResourceService{TResource, TId}" />
36+
/// and an implementation type of <see cref="NoSqlResourceService{TResource,TId}" />.
37+
/// </summary>
38+
/// <param name="services">The <see cref="IServiceCollection" />.</param>
39+
/// <param name="assembly">The <see cref="Assembly" /> containing the annotated resources.</param>
40+
/// <returns>The <see cref="IServiceCollection" />.</returns>
41+
public static IServiceCollection AddNoSqlResourceServices(this IServiceCollection services, Assembly assembly)
42+
{
43+
services.AddScoped<INoSqlQueryLayerComposer, NoSqlQueryLayerComposer>();
44+
45+
foreach (Type resourceType in assembly.ExportedTypes.Where(IsNoSqlResource))
46+
{
47+
if (TryGetIdType(resourceType, out Type? idType))
48+
{
49+
services.AddScoped(
50+
typeof(IResourceService<,>).MakeGenericType(resourceType, idType),
51+
typeof(NoSqlResourceService<,>).MakeGenericType(resourceType, idType));
52+
}
53+
}
54+
55+
return services;
56+
}
57+
58+
private static bool IsNoSqlResource(Type type)
59+
{
60+
return Attribute.GetCustomAttribute(type, typeof(NoSqlResourceAttribute)) is not null &&
61+
type.GetInterfaces().Any(IsGenericIIdentifiable);
62+
}
63+
64+
private static bool IsGenericIIdentifiable(Type type)
65+
{
66+
return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IIdentifiable<>);
67+
}
68+
69+
private static bool TryGetIdType(Type resourceType, [NotNullWhen(true)] out Type? idType)
70+
{
71+
Type? identifiableInterface = resourceType.GetInterfaces().FirstOrDefault(IsGenericIIdentifiable);
72+
idType = identifiableInterface?.GetGenericArguments()[0];
73+
return idType is not null;
74+
}
75+
}
76+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Queries.Expressions;
4+
5+
namespace JsonApiDotNetCore.Queries
6+
{
7+
/// <summary>
8+
/// Takes scoped expressions from <see cref="IQueryConstraintProvider" />s and transforms them.
9+
/// Additionally provides specific transformations for NoSQL databases without support for joins.
10+
/// </summary>
11+
[PublicAPI]
12+
public interface INoSqlQueryLayerComposer
13+
{
14+
/// <summary>
15+
/// Builds a filter from constraints, used to determine total resource count on a primary
16+
/// collection endpoint.
17+
/// </summary>
18+
FilterExpression? GetPrimaryFilterFromConstraintsForNoSql(ResourceType primaryResourceType);
19+
20+
/// <summary>
21+
/// Composes a <see cref="QueryLayer" /> and an <see cref="IncludeExpression" /> from the
22+
/// constraints specified by the request. Used for primary resources.
23+
/// </summary>
24+
(QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType);
25+
26+
/// <summary>
27+
/// Composes a <see cref="QueryLayer" /> and an <see cref="IncludeExpression" /> from the
28+
/// constraints specified by the request. Used for primary resources.
29+
/// </summary>
30+
(QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql<TId>(
31+
TId id,
32+
ResourceType primaryResourceType,
33+
TopFieldSelection fieldSelection)
34+
where TId : notnull;
35+
36+
37+
/// <summary>
38+
/// Composes a <see cref="QueryLayer" /> with a filter expression in the form "equals(id,'{stringId}')".
39+
/// </summary>
40+
QueryLayer ComposeForGetByIdForNoSql<TId>(TId id, ResourceType primaryResourceType)
41+
where TId : notnull;
42+
43+
/// <summary>
44+
/// Composes a <see cref="QueryLayer" /> from the constraints specified by the request
45+
/// and a filter expression in the form "equals({propertyName},'{propertyValue}')".
46+
/// Used for secondary or included resources.
47+
/// </summary>
48+
/// <param name="requestResourceType">The <see cref="ResourceType" /> of the secondary or included resource.</param>
49+
/// <param name="propertyName">The name of the property of the secondary or included resource used for filtering.</param>
50+
/// <param name="propertyValue">The value of the property of the secondary or included resource used for filtering.</param>
51+
/// <param name="isIncluded">
52+
/// <see langword="true" />, if the resource is included by the request (e.g., "{url}?include={relationshipName}");
53+
/// <see langword="false" />, if the resource is a secondary resource (e.g., "/{primary}/{id}/{relationshipName}").
54+
/// </param>
55+
/// <returns>A tuple with a <see cref="QueryLayer" /> and an <see cref="IncludeExpression" />.</returns>
56+
(QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsAndFilterForNoSql(
57+
ResourceType requestResourceType,
58+
string propertyName,
59+
string propertyValue,
60+
bool isIncluded);
61+
62+
/// <summary>
63+
/// Builds a query that retrieves the primary resource, including all of its attributes
64+
/// and all targeted relationships, during a create/update/delete request.
65+
/// </summary>
66+
(QueryLayer QueryLayer, IncludeExpression Include) ComposeForUpdateForNoSql<TId>(TId id, ResourceType primaryResourceType)
67+
where TId : notnull;
68+
}
69+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
using System.Collections.Generic;
2+
using System.Collections.Immutable;
3+
using System.Linq;
4+
using JetBrains.Annotations;
5+
using JsonApiDotNetCore.Configuration;
6+
using JsonApiDotNetCore.Queries.Expressions;
7+
using JsonApiDotNetCore.Queries.Internal;
8+
using JsonApiDotNetCore.Resources;
9+
using JsonApiDotNetCore.Resources.Annotations;
10+
11+
namespace JsonApiDotNetCore.Queries
12+
{
13+
/// <summary>
14+
/// Default implementation of the <see cref="INoSqlQueryLayerComposer" />.
15+
/// </summary>
16+
/// <remarks>
17+
/// Register <see cref="NoSqlQueryLayerComposer"/> with the service container as
18+
/// shown in the following example.
19+
/// </remarks>
20+
/// <example>
21+
/// <code><![CDATA[
22+
/// public class Startup
23+
/// {
24+
/// public void ConfigureServices(IServiceCollection services)
25+
/// {
26+
/// services.AddNoSqlResourceServices();
27+
/// }
28+
/// }
29+
/// ]]></code>
30+
/// </example>
31+
[PublicAPI]
32+
public class NoSqlQueryLayerComposer : QueryLayerComposer, INoSqlQueryLayerComposer
33+
{
34+
private readonly IEnumerable<IQueryConstraintProvider> _constraintProviders;
35+
private readonly ITargetedFields _targetedFields;
36+
37+
public NoSqlQueryLayerComposer(
38+
IEnumerable<IQueryConstraintProvider> constraintProviders,
39+
IResourceDefinitionAccessor resourceDefinitionAccessor,
40+
IJsonApiOptions options,
41+
IPaginationContext paginationContext,
42+
ITargetedFields targetedFields,
43+
IEvaluatedIncludeCache evaluatedIncludeCache,
44+
ISparseFieldSetCache sparseFieldSetCache) : base(constraintProviders, resourceDefinitionAccessor, options, paginationContext, targetedFields, evaluatedIncludeCache, sparseFieldSetCache)
45+
{
46+
_constraintProviders = constraintProviders;
47+
_targetedFields = targetedFields;
48+
}
49+
50+
/// <inheritdoc />
51+
public FilterExpression? GetPrimaryFilterFromConstraintsForNoSql(ResourceType primaryResourceType)
52+
{
53+
return GetPrimaryFilterFromConstraints(primaryResourceType);
54+
}
55+
56+
/// <inheritdoc />
57+
public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsForNoSql(ResourceType requestResourceType)
58+
{
59+
QueryLayer queryLayer = ComposeFromConstraints(requestResourceType);
60+
IncludeExpression include = queryLayer.Include ?? IncludeExpression.Empty;
61+
62+
queryLayer.Include = IncludeExpression.Empty;
63+
queryLayer.Projection = null;
64+
65+
return (queryLayer, include);
66+
}
67+
68+
/// <inheritdoc />
69+
public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForGetByIdWithConstraintsForNoSql<TId>(
70+
TId id,
71+
ResourceType primaryResourceType,
72+
TopFieldSelection fieldSelection)
73+
where TId : notnull
74+
{
75+
QueryLayer queryLayer = ComposeForGetById(id, primaryResourceType, fieldSelection);
76+
IncludeExpression include = queryLayer.Include ?? IncludeExpression.Empty;
77+
78+
queryLayer.Include = IncludeExpression.Empty;
79+
queryLayer.Projection = null;
80+
81+
return (queryLayer, include);
82+
}
83+
84+
/// <inheritdoc />
85+
public QueryLayer ComposeForGetByIdForNoSql<TId>(TId id, ResourceType primaryResourceType)
86+
where TId : notnull
87+
{
88+
return new QueryLayer(primaryResourceType)
89+
{
90+
Filter = new ComparisonExpression(
91+
ComparisonOperator.Equals,
92+
new ResourceFieldChainExpression(
93+
primaryResourceType.Fields.Single(f => f.Property.Name == nameof(IIdentifiable<TId>.Id))),
94+
new LiteralConstantExpression(id.ToString()!)),
95+
Include = IncludeExpression.Empty,
96+
};
97+
}
98+
99+
/// <inheritdoc />
100+
public (QueryLayer QueryLayer, IncludeExpression Include) ComposeFromConstraintsAndFilterForNoSql(
101+
ResourceType requestResourceType,
102+
string propertyName,
103+
string propertyValue,
104+
bool isIncluded)
105+
{
106+
// Compose a secondary resource filter in the form "equals({propertyName},'{propertyValue}')".
107+
FilterExpression[] secondaryResourceFilterExpressions =
108+
{
109+
ComposeSecondaryResourceFilter(requestResourceType, propertyName, propertyValue),
110+
};
111+
112+
// Get the query expressions from the request.
113+
ExpressionInScope[] constraints = _constraintProviders
114+
.SelectMany(provider => provider.GetConstraints())
115+
.ToArray();
116+
117+
bool IsQueryLayerConstraint(ExpressionInScope constraint)
118+
{
119+
return constraint.Expression is not IncludeExpression &&
120+
(!isIncluded || (constraint.Scope is not null &&
121+
constraint.Scope.Fields.Any(field => field.PublicName == requestResourceType.PublicName)));
122+
}
123+
124+
IEnumerable<QueryExpression> requestQueryExpressions = constraints
125+
.Where(IsQueryLayerConstraint)
126+
.Select(constraint => constraint.Expression);
127+
128+
// Combine the secondary resource filter and request query expressions and
129+
// create the query layer from the combined query expressions.
130+
QueryExpression[] queryExpressions = secondaryResourceFilterExpressions
131+
.Concat(requestQueryExpressions)
132+
.ToArray();
133+
134+
var queryLayer = new QueryLayer(requestResourceType)
135+
{
136+
Include = IncludeExpression.Empty,
137+
Filter = GetFilter(queryExpressions, requestResourceType),
138+
Sort = GetSort(queryExpressions, requestResourceType),
139+
Pagination = GetPagination(queryExpressions, requestResourceType),
140+
};
141+
142+
// Retrieve the IncludeExpression from the constraints collection.
143+
// There will be zero or one IncludeExpression, even if multiple include query
144+
// parameters were specified in the request. JsonApiDotNetCore combines those
145+
// into a single expression.
146+
IncludeExpression include = isIncluded
147+
? IncludeExpression.Empty
148+
: constraints
149+
.Select(constraint => constraint.Expression)
150+
.OfType<IncludeExpression>()
151+
.DefaultIfEmpty(IncludeExpression.Empty)
152+
.Single();
153+
154+
return (queryLayer, include);
155+
}
156+
157+
private static FilterExpression ComposeSecondaryResourceFilter(
158+
ResourceType resourceType,
159+
string propertyName,
160+
string properyValue)
161+
{
162+
return new ComparisonExpression(
163+
ComparisonOperator.Equals,
164+
new ResourceFieldChainExpression(resourceType.Fields.Single(f => f.Property.Name == propertyName)),
165+
new LiteralConstantExpression(properyValue));
166+
}
167+
168+
public (QueryLayer QueryLayer, IncludeExpression Include) ComposeForUpdateForNoSql<TId>(
169+
TId id,
170+
ResourceType primaryResourceType)
171+
where TId : notnull
172+
{
173+
// Create primary layer without an include expression.
174+
AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType);
175+
QueryLayer primaryLayer = new(primaryResourceType)
176+
{
177+
Include = IncludeExpression.Empty,
178+
Filter = new ComparisonExpression(
179+
ComparisonOperator.Equals,
180+
new ResourceFieldChainExpression(primaryIdAttribute),
181+
new LiteralConstantExpression(id.ToString()!)),
182+
};
183+
184+
// Create a separate include expression.
185+
ImmutableHashSet<IncludeElementExpression> includeElements = _targetedFields.Relationships
186+
.Select(relationship => new IncludeElementExpression(relationship))
187+
.ToImmutableHashSet();
188+
189+
IncludeExpression include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty;
190+
191+
return (primaryLayer, include);
192+
}
193+
194+
private static AttrAttribute GetIdAttribute(ResourceType resourceType)
195+
{
196+
return resourceType.GetAttributeByPropertyName(nameof(Identifiable<object>.Id));
197+
}
198+
}
199+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System;
2+
using JetBrains.Annotations;
3+
4+
namespace JsonApiDotNetCore.Resources.Annotations
5+
{
6+
/// <summary>
7+
/// Used to provide additional information for a JSON:API relationship with a foreign key
8+
/// (https://jsonapi.org/format/#document-resource-object-relationships).
9+
/// </summary>
10+
/// <example>
11+
/// <code><![CDATA[
12+
/// public class Author : Identifiable<Guid>
13+
/// {
14+
/// [HasMany]
15+
/// [NoSqlHasForeignKey(nameof(Article.AuthorId))]
16+
/// public ICollection<Article> Articles { get; set; }
17+
/// }
18+
///
19+
/// public class Article : Identifiable<Guid>
20+
/// {
21+
/// public Guid AuthorId { get; set; }
22+
///
23+
/// [HasOne]
24+
/// [NoSqlHasForeignKey(nameof(AuthorId))]
25+
/// public Author Author { get; set; }
26+
/// }
27+
/// ]]></code>
28+
/// </example>
29+
[PublicAPI]
30+
[AttributeUsage(AttributeTargets.Property)]
31+
public class NoSqlHasForeignKeyAttribute : Attribute
32+
{
33+
public NoSqlHasForeignKeyAttribute(string propertyName)
34+
{
35+
PropertyName = propertyName;
36+
}
37+
38+
/// <summary>
39+
/// Gets the name of the foreign key property corresponding to the annotated
40+
/// navigation property.
41+
/// </summary>
42+
public string PropertyName { get; private set; }
43+
44+
/// <summary>
45+
/// Gets or sets a value indicating whether the navigation property is on
46+
/// the dependent side of the foreign key relationship. The default is
47+
/// <see langword="true" />.
48+
/// </summary>
49+
public bool IsDependent { get; set; } = true;
50+
}
51+
}

0 commit comments

Comments
 (0)