From 7c853a5adf44a459e7cc65451c10f250070a20a4 Mon Sep 17 00:00:00 2001 From: Simon Wacker Date: Wed, 20 May 2026 16:56:01 +0200 Subject: [PATCH] Upgrade to HotChocolate 16 --- backend/create-certificates.csx | 44 ---- backend/dotnet-tools.json | 4 +- .../src/Configuration/AuthConfiguration.cs | 19 +- .../src/Configuration/GraphQlConfiguration.cs | 196 +++++++++++------- .../Controllers/AuthenticationController.cs | 16 +- backend/src/Data/ApplicationDbContext.cs | 128 +++++++++++- backend/src/Data/Association.cs | 8 + backend/src/Data/AuditableAssociation.cs | 10 + backend/src/Data/AuditableEntity.cs | 20 ++ backend/src/Data/CalorimetricData.cs | 5 +- backend/src/Data/DataX.cs | 52 +++-- backend/src/Data/Entity.cs | 15 +- backend/src/Data/GeometricData.cs | 5 +- backend/src/Data/GetHttpsResource.cs | 2 +- backend/src/Data/HygrothermalData.cs | 5 +- backend/src/Data/IAssociation.cs | 7 + backend/src/Data/IAuditable.cs | 13 ++ backend/src/Data/IData.cs | 6 +- backend/src/Data/INamed.cs | 6 + backend/src/Data/InstitutionAccessRights.cs | 6 +- backend/src/Data/LifeCycleData.cs | 5 +- backend/src/Data/OpticalData.cs | 5 +- backend/src/Data/PhotovoltaicData.cs | 5 +- backend/src/Data/User.cs | 2 +- backend/src/Database.csproj | 66 +++--- .../src/Extensions/EnumerableExtensions.cs | 20 -- backend/src/Extensions/LinqExtensions.cs | 190 +++++++++++++++++ backend/src/Extensions/NodaTimeExtensions.cs | 15 +- backend/src/Extensions/StringExtensions.cs | 15 ++ .../AuditableAssociationFilterType.cs | 19 ++ .../AuditableAssociationSortType.cs | 19 ++ backend/src/GraphQl/AuthorizedConnection.cs | 40 ++++ .../GraphQl/AuthorizedPaginatedConnection.cs | 47 +++++ .../CalorimetricDataByIdDataLoader.cs | 20 -- .../CalorimetricDataLoaders.cs | 31 +++ .../CalorimetricDataQueries.cs | 13 +- .../CalorimetricDataX/CalorimetricDataType.cs | 2 +- .../CreateCalorimetricDataMutation.cs | 7 +- backend/src/GraphQl/Connection.cs | 31 +-- backend/src/GraphQl/DataLoaders.cs | 126 +++++++++++ .../GraphQl/DataX/CreateDataMutationBase.cs | 5 +- backend/src/GraphQl/DataX/DataConnection.cs | 14 +- .../src/GraphQl/DataX/DataConnectionBase.cs | 5 +- backend/src/GraphQl/DataX/DataDataLoaders.cs | 69 ++++++ .../src/GraphQl/DataX/DataFilterTypeBase.cs | 5 +- backend/src/GraphQl/DataX/DataQueries.cs | 1 + backend/src/GraphQl/DataX/DataQueriesBase.cs | 48 ++--- backend/src/GraphQl/DataX/DataResolvers.cs | 20 +- backend/src/GraphQl/DataX/DataSortTypeBase.cs | 5 +- backend/src/GraphQl/DataX/DataType.cs | 19 +- backend/src/GraphQl/DataX/DataTypeBase.cs | 21 +- .../src/GraphQl/DataX/GetHttpsResourceTree.cs | 7 +- ...ceTreeNonRootVerticesByDataIdDataLoader.cs | 34 --- ...HttpsResourceTreeRootByDataIdDataLoader.cs | 34 --- .../GetHttpsResourcesByDataIdDataLoader.cs | 34 --- .../src/GraphQl/DataX/UpdateDataMutation.cs | 4 +- .../AssociationsByAssociateIdDataLoader.cs | 38 ---- ...erType.cs => AuditableEntityFilterType.cs} | 13 +- ...SortType.cs => AuditableEntitySortType.cs} | 10 +- .../GraphQl/Entities/EntityByIdDataLoader.cs | 39 ---- backend/src/GraphQl/Entities/EntityType.cs | 10 +- .../ErrorLoggingDiagnosticEventListener.cs | 187 +++++++++-------- .../src/GraphQl/Extensions/PageExtensions.cs | 66 ++++++ .../Extensions/ResolverContextExtensions.cs | 14 +- .../Extensions/SortingContextExtensions.cs | 30 --- backend/src/GraphQl/Filters/INotField.cs | 21 +- backend/src/GraphQl/Filters/NotField.cs | 87 ++++---- .../GraphQl/Filters/ScalarFilterInputTypes.cs | 64 ++++-- .../CreateGeometricDataMutation.cs | 9 +- .../GeometricDataByIdDataLoader.cs | 20 -- .../GeometricDataX/GeometricDataLoaders.cs | 31 +++ .../GeometricDataX/GeometricDataQueries.cs | 11 +- .../GeometricDataX/GeometricDataType.cs | 2 +- .../GetHttpsResourceByIdDataLoader.cs | 20 -- ...eChildrenByGetHttpsResourceIdDataLoader.cs | 26 --- .../GetHttpsResourceDataLoaders.cs | 49 +++++ .../GetHttpsResourceFilterType.cs | 2 +- .../GetHttpsResourceQueries.cs | 38 ++-- .../GetHttpsResourceResolvers.cs | 16 +- .../GetHttpsResourceSortType.cs | 2 +- .../GetHttpsResources/GetHttpsResourceType.cs | 3 +- ...mputeGetHttpsResourceHashValuesMutation.cs | 5 +- backend/src/GraphQl/GraphQlConstants.cs | 2 + backend/src/GraphQl/GraphQlThrowHelper.cs | 52 +++++ backend/src/GraphQl/GraphQlTypeResources.cs | 8 + .../CreateHygrothermalDataMutation.cs | 7 +- .../HygrothermalDataByIdDataLoader.cs | 20 -- .../HygrothermalDataLoaders.cs | 31 +++ .../HygrothermalDataQueries.cs | 11 +- .../HygrothermalDataX/HygrothermalDataType.cs | 2 +- .../CreateLifeCycleDataMutation.cs | 7 +- .../LifeCycleDataByIdDataLoader.cs | 20 -- .../LifeCycleDataX/LifeCycleDataLoaders.cs | 31 +++ .../LifeCycleDataX/LifeCycleDataQueries.cs | 11 +- .../LifeCycleDataX/LifeCycleDataType.cs | 2 +- .../OpticalDataX/CreateOpticalDataMutation.cs | 7 +- .../OpticalDataX/OpticalDataByIdDataLoader.cs | 20 -- .../OpticalDataX/OpticalDataLoaders.cs | 31 +++ .../OpticalDataX/OpticalDataQueries.cs | 11 +- .../GraphQl/OpticalDataX/OpticalDataType.cs | 2 +- backend/src/GraphQl/PaginatedConnection.cs | 75 +++++++ backend/src/GraphQl/PaginatedEdge.cs | 31 +++ .../CreatePhotovoltaicDataMutation.cs | 9 +- .../PhotovoltaicDataByIdDataLoader.cs | 20 -- .../PhotovoltaicDataLoaders.cs | 31 +++ .../PhotovoltaicDataQueries.cs | 13 +- .../PhotovoltaicDataX/PhotovoltaicDataType.cs | 2 +- .../CreateResponseApprovalsMutation.cs | 3 +- .../ResponseApprovalFilterType.cs | 3 +- .../UpdateResponseApprovalsMutation.cs | 3 +- .../src/GraphQl/{ => Scalars}/LocaleType.cs | 6 +- backend/src/GraphQl/Scalars/MyUriType.cs | 110 ++++++++++ .../src/GraphQl/Scalars/NonNegativeIntType.cs | 72 +++++++ backend/src/GraphQl/Sorting.cs | 18 ++ .../src/GraphQl/Users/UserByIdDataLoader.cs | 20 -- backend/src/GraphQl/Users/UserDataLoaders.cs | 31 +++ backend/src/GraphQl/Users/UserType.cs | 3 +- ...ningAndEncryptionCertificateRotationJob.cs | 20 +- .../SpectralToIntegralMethod.cs | 2 +- backend/src/Program.cs | 1 - backend/src/Services/AccessRightsService.cs | 12 +- backend/src/Services/CacheService.cs | 10 +- .../src/Services/ResponseApprovalService.cs | 7 +- backend/src/Services/SigningService.cs | 2 +- backend/src/Startup.cs | 37 +--- backend/src/Utilities/FileHelpers.cs | 8 +- backend/test/AuditableTests.cs | 75 +++++++ backend/test/Database.Tests.csproj | 14 +- 128 files changed, 2191 insertions(+), 1105 deletions(-) delete mode 100644 backend/create-certificates.csx create mode 100644 backend/src/Data/Association.cs create mode 100644 backend/src/Data/AuditableAssociation.cs create mode 100644 backend/src/Data/AuditableEntity.cs create mode 100644 backend/src/Data/IAssociation.cs create mode 100644 backend/src/Data/IAuditable.cs create mode 100644 backend/src/Data/INamed.cs delete mode 100644 backend/src/Extensions/EnumerableExtensions.cs create mode 100644 backend/src/Extensions/LinqExtensions.cs create mode 100644 backend/src/GraphQl/Associations/AuditableAssociationFilterType.cs create mode 100644 backend/src/GraphQl/Associations/AuditableAssociationSortType.cs create mode 100644 backend/src/GraphQl/AuthorizedConnection.cs create mode 100644 backend/src/GraphQl/AuthorizedPaginatedConnection.cs delete mode 100644 backend/src/GraphQl/CalorimetricDataX/CalorimetricDataByIdDataLoader.cs create mode 100644 backend/src/GraphQl/CalorimetricDataX/CalorimetricDataLoaders.cs create mode 100644 backend/src/GraphQl/DataLoaders.cs create mode 100644 backend/src/GraphQl/DataX/DataDataLoaders.cs delete mode 100644 backend/src/GraphQl/DataX/GetHttpsResourceTreeNonRootVerticesByDataIdDataLoader.cs delete mode 100644 backend/src/GraphQl/DataX/GetHttpsResourceTreeRootByDataIdDataLoader.cs delete mode 100644 backend/src/GraphQl/DataX/GetHttpsResourcesByDataIdDataLoader.cs delete mode 100644 backend/src/GraphQl/Entities/AssociationsByAssociateIdDataLoader.cs rename backend/src/GraphQl/Entities/{EntityFilterType.cs => AuditableEntityFilterType.cs} (90%) rename backend/src/GraphQl/Entities/{EntitySortType.cs => AuditableEntitySortType.cs} (58%) delete mode 100644 backend/src/GraphQl/Entities/EntityByIdDataLoader.cs create mode 100644 backend/src/GraphQl/Extensions/PageExtensions.cs delete mode 100644 backend/src/GraphQl/Extensions/SortingContextExtensions.cs delete mode 100644 backend/src/GraphQl/GeometricDataX/GeometricDataByIdDataLoader.cs create mode 100644 backend/src/GraphQl/GeometricDataX/GeometricDataLoaders.cs delete mode 100644 backend/src/GraphQl/GetHttpsResources/GetHttpsResourceByIdDataLoader.cs delete mode 100644 backend/src/GraphQl/GetHttpsResources/GetHttpsResourceChildrenByGetHttpsResourceIdDataLoader.cs create mode 100644 backend/src/GraphQl/GetHttpsResources/GetHttpsResourceDataLoaders.cs create mode 100644 backend/src/GraphQl/GraphQlThrowHelper.cs create mode 100644 backend/src/GraphQl/GraphQlTypeResources.cs delete mode 100644 backend/src/GraphQl/HygrothermalDataX/HygrothermalDataByIdDataLoader.cs create mode 100644 backend/src/GraphQl/HygrothermalDataX/HygrothermalDataLoaders.cs delete mode 100644 backend/src/GraphQl/LifeCycleDataX/LifeCycleDataByIdDataLoader.cs create mode 100644 backend/src/GraphQl/LifeCycleDataX/LifeCycleDataLoaders.cs delete mode 100644 backend/src/GraphQl/OpticalDataX/OpticalDataByIdDataLoader.cs create mode 100644 backend/src/GraphQl/OpticalDataX/OpticalDataLoaders.cs create mode 100644 backend/src/GraphQl/PaginatedConnection.cs create mode 100644 backend/src/GraphQl/PaginatedEdge.cs delete mode 100644 backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataByIdDataLoader.cs create mode 100644 backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataLoaders.cs rename backend/src/GraphQl/{ => Scalars}/LocaleType.cs (94%) create mode 100644 backend/src/GraphQl/Scalars/MyUriType.cs create mode 100644 backend/src/GraphQl/Scalars/NonNegativeIntType.cs create mode 100644 backend/src/GraphQl/Sorting.cs delete mode 100644 backend/src/GraphQl/Users/UserByIdDataLoader.cs create mode 100644 backend/src/GraphQl/Users/UserDataLoaders.cs create mode 100644 backend/test/AuditableTests.cs diff --git a/backend/create-certificates.csx b/backend/create-certificates.csx deleted file mode 100644 index 1f92e4c0..00000000 --- a/backend/create-certificates.csx +++ /dev/null @@ -1,44 +0,0 @@ -using System.Linq; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; - -// TODO Is there a better way to manage these keys? Is there gonna be a problem in two-years time when the keys become invalid? -// Inspired by https://github.com/openiddict/openiddict-core/blob/78901e3e7e3ee47cf7846a71f758dc9ca110b1a2/src/OpenIddict.Server/OpenIddictServerBuilder.cs#L661-L679 -// and https://github.com/openiddict/openiddict-core/blob/78901e3e7e3ee47cf7846a71f758dc9ca110b1a2/src/OpenIddict.Server/OpenIddictServerBuilder.cs#L661-L679 -foreach (var (fileName, name, flags, password) in new[] { - ("jwt-encryption-certificate.pfx", "Encryption", X509KeyUsageFlags.KeyEncipherment, Args[0]), - ("jwt-signing-certificate.pfx", "Signing", X509KeyUsageFlags.DigitalSignature, Args[1]) -}) -{ - var path = Path.Join("src", fileName); - var certificate = - File.Exists(path) - ? X509CertificateLoader.LoadPkcs12FromFile(path, password) - : null; - if (certificate is null || certificate.NotAfter <= DateTime.Now) - { - var subject = new X500DistinguishedName($"CN=Database OpenId Connect Server {name} Certificate"); - // certificates.LastOrDefault(certificate => certificate.NotBefore < DateTime.Now && certificate.NotAfter > DateTime.Now); - // TODO Is RSA sufficiently secure? Or should we use ECDSA? - using (var algorithm = RSA.Create(keySizeInBits: 2048)) - { - var request = new CertificateRequest( - subject, - algorithm, - HashAlgorithmName.SHA256, - RSASignaturePadding.Pkcs1 - ); - request.CertificateExtensions.Add( - new X509KeyUsageExtension(flags, critical: true) - ); - certificate = request.CreateSelfSigned( - notBefore: DateTimeOffset.UtcNow, - notAfter: DateTimeOffset.UtcNow.AddYears(2) - ); - File.WriteAllBytes( - path, - certificate.Export(X509ContentType.Pfx, password) - ); - } - } -} \ No newline at end of file diff --git a/backend/dotnet-tools.json b/backend/dotnet-tools.json index 6761c304..972eb2a4 100644 --- a/backend/dotnet-tools.json +++ b/backend/dotnet-tools.json @@ -24,7 +24,7 @@ "rollForward": false }, "dotnet-ef": { - "version": "10.0.5", + "version": "10.0.8", "commands": [ "dotnet-ef" ], @@ -66,7 +66,7 @@ "rollForward": false }, "jetbrains.resharper.globaltools": { - "version": "2025.3.3", + "version": "2026.1.1", "commands": [ "jb" ], diff --git a/backend/src/Configuration/AuthConfiguration.cs b/backend/src/Configuration/AuthConfiguration.cs index 51f7100c..6a7e65ad 100644 --- a/backend/src/Configuration/AuthConfiguration.cs +++ b/backend/src/Configuration/AuthConfiguration.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Net.Http; -using System.Reflection; using System.Security.Cryptography.X509Certificates; using Database.Authentication; using Database.Authorization; @@ -10,6 +9,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using NodaTime; using OpenIddict.Abstractions; using OpenIddict.Client; using Quartz; @@ -21,7 +21,7 @@ public static class AuthConfiguration { private static readonly TimeSpan s_cookieExpirationTimeSpan = TimeSpan.FromDays(1); - private static void BootstrapCertificates() + private static void BootstrapCertificates(IClock clock) { using var store = new X509Store(OpenIdConnectConstants.CertificateStoreName, OpenIdConnectConstants.CertificateStoreLocation); try @@ -34,11 +34,12 @@ private static void BootstrapCertificates() distinguishedName, validOnly: true ); - if (certificates.Count == 0) + if (certificates.Count is 0) { store.Add( JwtSigningAndEncryptionCertificateRotationJob.CreateSigningCertificate( - distinguishedName + distinguishedName, + clock ) ); } @@ -50,11 +51,12 @@ private static void BootstrapCertificates() distinguishedName, validOnly: true ); - if (certificates.Count == 0) + if (certificates.Count is 0) { store.Add( JwtSigningAndEncryptionCertificateRotationJob.CreateEncryptionCertificate( - distinguishedName + distinguishedName, + clock ) ); } @@ -93,10 +95,11 @@ private static IEnumerable FindCertificates(string distinguish public static void ConfigureServices( IServiceCollection services, IWebHostEnvironment environment, - AppSettings appSettings + AppSettings appSettings, + IClock clock ) { - BootstrapCertificates(); + BootstrapCertificates(clock); services.AddScoped(); services.AddScoped(); ConfigureAuthenticationAndAuthorizationServices(services); diff --git a/backend/src/Configuration/GraphQlConfiguration.cs b/backend/src/Configuration/GraphQlConfiguration.cs index 69fbdf38..f485fdbd 100644 --- a/backend/src/Configuration/GraphQlConfiguration.cs +++ b/backend/src/Configuration/GraphQlConfiguration.cs @@ -4,6 +4,8 @@ using Database.GraphQl; using Database.GraphQl.DataX; using Database.GraphQl.Filters; +using Database.GraphQl.Scalars; +using HotChocolate.AspNetCore; using HotChocolate.Configuration; using HotChocolate.Data; using HotChocolate.Data.Filters; @@ -13,9 +15,10 @@ using HotChocolate.Language; using HotChocolate.Types; using HotChocolate.Types.Descriptors; -using HotChocolate.Types.Descriptors.Definitions; +using HotChocolate.Types.Descriptors.Configurations; using HotChocolate.Types.NodaTime; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -32,8 +35,7 @@ IWebHostEnvironment environment { // Automatic-Persisted-Queries Services services - .AddMemoryCache() - .AddSha256DocumentHashProvider(HashFormat.Hex); // https://chillicream.com/docs/hotchocolate/v15/security/#fips-compliance + .AddMemoryCache(); // GraphQL Server var serverBuilder = services .AddGraphQLServer(); @@ -43,59 +45,92 @@ IWebHostEnvironment environment serverBuilder.TryAddTypeInterceptor(); } serverBuilder - // TODO add warmup task once we upgrade to version 16: https://chillicream.com/docs/hotchocolate/v16/server/warmup - // .AddWarmupTask(async (executor, cancellationToken) => - // { - // await executor.ExecuteAsync("{ __typename }", cancellationToken); - // }) + .AddSha256DocumentHashProvider(HashFormat.Hex) // https://chillicream.com/docs/hotchocolate/v15/security/#fips-compliance + .AddApplicationService() // for `AddHttpRequestInterceptor` + .AddApplicationService>() // for `AddDiagnosticEventListener` .DisableIntrospection(false) // if the introspection result becomes too big we need to disable it in production - .BindRuntimeType() - // Services https://chillicream.com/docs/hotchocolate/v13/integrations/entity-framework#registerdbcontext .RegisterDbContextFactory() + // .AddInstrumentation() .AddMutationConventions(new MutationConventionOptions { ApplyToAllMutations = false }) // Extensions + .AddNodaTime() + // .AddTypeConverter( + // _ => _.ToDateTimeOffset() + // ) + // .AddTypeConverter( + // _ => OffsetDateTime.FromDateTimeOffset(_) + // ) .AddProjections() .AddFiltering() .AddSorting() .AddConvention() .AddQueryContext() .AddAuthorization() - .AddGlobalObjectIdentification() .AddQueryFieldToMutationPayloads() - .ModifyOptions(options => + .AddGlobalObjectIdentification(_ => + { + // _.MaxAllowedNodeBatchSize = 100; + _.EnsureAllNodesCanBeResolved = true; + } + ) + .ModifyOptions(_ => { // https://github.com/ChilliCream/hotchocolate/blob/main/src/HotChocolate/Core/src/Types/Configuration/Contracts/ISchemaOptions.cs - options.StrictValidation = true; - options.UseXmlDocumentation = false; - options.SortFieldsByName = true; - options.RemoveUnreachableTypes = false; - options.RemoveUnusedTypeSystemDirectives = true; - options.DefaultBindingBehavior = BindingBehavior.Implicit; + _.StrictValidation = true; + _.UseXmlDocumentation = false; + _.SortFieldsByName = true; + _.RemoveUnreachableTypes = false; + _.RemoveUnusedTypeSystemDirectives = true; + _.DefaultBindingBehavior = BindingBehavior.Implicit; // options.DefaultFieldBindingFlags = FieldBindingFlags.InstanceAndStatic; - options.EnableDirectiveIntrospection = true; - options.DefaultDirectiveVisibility = DirectiveVisibility.Public; - options.DefaultResolverStrategy = ExecutionStrategy.Parallel; - options.ValidatePipelineOrder = true; - options.StrictRuntimeTypeValidation = true; - options.EnableOneOf = true; - options.EnsureAllNodesCanBeResolved = true; - options.EnableFlagEnums = false; - options.EnableDefer = false; - options.EnableStream = false; - options.EnableSemanticNonNull = false; - options.StripLeadingIFromInterface = false; - options.EnableTag = true; - options.PublishRootFieldPagesToPromiseCache = true; + _.EnableDirectiveIntrospection = true; + _.EnableOptInFeatures = true; + _.DefaultDirectiveVisibility = DirectiveVisibility.Public; + _.DefaultResolverStrategy = ExecutionStrategy.Parallel; + _.ValidatePipelineOrder = true; + _.StrictRuntimeTypeValidation = true; + _.EnableFlagEnums = false; + _.EnableDefer = false; + _.EnableStream = false; + _.StripLeadingIFromInterface = false; + _.EnableTag = true; + _.PublishRootFieldPagesToPromiseCache = true; + // options.OperationDocumentCacheSize = 200; + // options.PreparedOperationCacheSize = 100; } ) - .ModifyRequestOptions(options => + .ModifyServerOptions(_ => + { + _.AllowedGetOperations = AllowedGetOperations.Query; + _.Batching = AllowedBatching.All; + _.EnableGetRequests = false; + _.EnableMultipartRequests = true; + _.EnableSchemaRequests = true; + // Nitro + _.Tool.DisableTelemetry = true; + _.Tool.Enable = true; // environment.IsDevelopment() + _.Tool.GraphQLEndpoint = GraphQlConstants.EndpointPath; + _.Tool.IncludeCookies = false; + _.Tool.Title = "GraphQL"; + _.Tool.UseBrowserUrlAsGraphQLEndpoint = false; + _.Tool.UseGet = false; + } + ) + .ModifyRequestOptions(_ => { // https://github.com/ChilliCream/hotchocolate/blob/main/src/HotChocolate/Core/src/Execution/Options/RequestExecutorOptions.cs - options.ExecutionTimeout = TimeSpan.FromSeconds(120); - options.IncludeExceptionDetails = !environment.IsProduction(); // Default is `Debugger.IsAttached`. - /* options.QueryCacheSize = ...; */ - /* options.UseComplexityMultipliers = ...; */ - options.EnableSchemaFileSupport = true; + _.ExecutionTimeout = TimeSpan.FromSeconds(120); + _.IncludeExceptionDetails = !environment.IsProduction(); // Default is `Debugger.IsAttached`. + _.AllowErrorHandlingModeOverride = true; + // options.QueryCacheSize = ...; + // options.UseComplexityMultipliers = ...; + // options.EnableSchemaFileSupport = true; + } + ) + .ModifyCostOptions(_ => + { + _.MaxFieldCost = 10000; + _.MaxTypeCost = 10000; } ) // Configure @@ -108,11 +143,6 @@ IWebHostEnvironment environment // Persisted queries /* .AddFileSystemOperationDocumentStorage("./persisted_operations") */ /* .UsePersistedOperationPipeline(); */ - // HotChocolate uses the default authentication scheme, - // which we set to `null` in `AuthConfiguration` to force - // users to be explicit about what scheme to use when - // making it easier to grasp the various authentication - // flows. .AddHttpRequestInterceptor(async (httpContext, requestExecutor, requestBuilder, cancellationToken) => { await httpContext.RequestServices @@ -125,23 +155,12 @@ await httpContext.RequestServices ) ) // Scalar Types + // TODO Add `MyUuidType` based on https://github.com/ChilliCream/graphql-platform/blob/main/src/HotChocolate/Core/src/Types/Types/Scalars/UuidType.cs .AddType(new UuidType("Uuid", defaultFormat: 'D')) // https://chillicream.com/docs/hotchocolate/defining-a-schema/scalars#uuid-type - .AddType(new UrlType("Url")) - .AddType(new JsonType("Any", BindingBehavior.Implicit)) // https://chillicream.com/blog/2023/02/08/new-in-hot-chocolate-13#json-scalar + .AddType(new MyUriType()) + .AddType(new AnyType("Any")) .AddType() - .AddType() - .AddType() - // .AddType() - // Register converters between NodaTime's `OffsetDateTime` and .NET's - // `DateTimeOffset` to reuse the existing `DateTimeType` - // https://chillicream.com/docs/hotchocolate/v15/defining-a-schema/scalars#custom-converters - .BindRuntimeType() - .AddTypeConverter( - _ => _.ToDateTimeOffset() - ) - .AddTypeConverter( - _ => OffsetDateTime.FromDateTimeOffset(_) - ) + .BindRuntimeType() // Object Types .AddType() // Query, Mutation, Subscription, Object, and Input Types @@ -152,10 +171,11 @@ await httpContext.RequestServices .AddTypes() // Paging .AddDbContextCursorPagingProvider() + // .AddCursorKeySerializer(new OffsetDateTimeCursorKeySerializer()) .ModifyPagingOptions(_ => { - _.MaxPageSize = 100; - _.DefaultPageSize = 100; + _.MaxPageSize = (int)GraphQlConstants.MaximumPageSize; + _.DefaultPageSize = (int)GraphQlConstants.MaximumPageSize; _.IncludeTotalCount = true; _.IncludeNodesField = false; // TODO I actually want to infer connection names from fields (which is the default in HotChocolate). However, the current `database.graphql` schema that I hand-wrote still infers connection names from types. @@ -166,6 +186,26 @@ await httpContext.RequestServices .UseAutomaticPersistedOperationPipeline() .AddInMemoryOperationDocumentStorage(); // Needed by the automatic persisted operation pipeline } + + // + // private sealed class MyUuidType : UuidType + // { + // private const string SpecifiedByString = "https://tools.ietf.org/html/rfc4122"; + // + // public MyUuidType( + // string name, + // string? description = null, + // char defaultFormat = '\0', + // bool enforceFormat = false, + // BindingBehavior bind = BindingBehavior.Explicit + // ) + // : base(name, description, defaultFormat, enforceFormat, + // bind) + // { + // SpecifiedBy = new Uri(SpecifiedByString, UriKind.Absolute); + // } + // } + } // https://github.com/ChilliCream/graphql-platform/blob/main/src/HotChocolate/Core/src/Types/Configuration/TypeInterceptor.cs @@ -177,14 +217,14 @@ public override void OnBeforeInitialize(ITypeDiscoveryContext discoveryContext) Console.WriteLine($"[INIT] Discovered type '{discoveryContext.Type.GetType().Name}'"); } - public override void OnBeforeCompleteName(ITypeCompletionContext completionContext, DefinitionBase definition) + public override void OnBeforeCompleteName(ITypeCompletionContext completionContext, TypeSystemConfiguration configuration) { - Console.WriteLine($"[NAME] Finalizing name '{definition.Name}' for type '{completionContext.Type.GetType().Name}'"); + Console.WriteLine($"[NAME] Finalizing name '{configuration.Name}' for type '{completionContext.Type.GetType().Name}'"); } - public override void OnAfterCompleteType(ITypeCompletionContext completionContext, DefinitionBase definition) + public override void OnAfterCompleteType(ITypeCompletionContext completionContext, TypeSystemConfiguration configuration) { - Console.WriteLine($"[DONE] Completed type '{completionContext.Type.GetType().Name}' with name '{definition.Name}'"); + Console.WriteLine($"[DONE] Completed type '{completionContext.Type.GetType().Name}' with name '{configuration.Name}'"); } } @@ -222,7 +262,9 @@ protected override void Configure(IFilterConventionDescriptor descriptor) descriptor.Provider( new QueryableFilterProvider(_ => _ .AddDefaultFieldHandlers() - .AddFieldHandler() + .AddFieldHandler(context => + new QueryableComparableInClosedIntervalHandler(context.TypeConverter, context.InputParser) + ) ) ); } @@ -364,16 +406,24 @@ this IFilterConventionDescriptor descriptor .BindRuntimeType() .BindRuntimeType() .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() .BindRuntimeType() .BindRuntimeType() - // .BindRuntimeType() - // .BindRuntimeType() - // .BindRuntimeType() - // .BindRuntimeType() - .BindRuntimeType() - .BindRuntimeType() - .BindRuntimeType() - .BindRuntimeType(); + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType() + .BindRuntimeType(); } } diff --git a/backend/src/Controllers/AuthenticationController.cs b/backend/src/Controllers/AuthenticationController.cs index c661c6ef..bd788125 100644 --- a/backend/src/Controllers/AuthenticationController.cs +++ b/backend/src/Controllers/AuthenticationController.cs @@ -46,7 +46,7 @@ protected override void Dispose(bool disposing) } [HttpGet("~/connect/login")] - public ActionResult LogIn(string? returnUrl) + public ActionResult LogIn(string? returnTo) { // Ask the OpenIddict client middleware to redirect the user agent to the identity provider. return Challenge( @@ -60,7 +60,7 @@ public ActionResult LogIn(string? returnUrl) ) { // Only allow local return URLs to prevent open redirect attacks. - RedirectUri = SanitizeReturnUrl(returnUrl) + RedirectUri = SanitizeReturnUrl(returnTo) }, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme ); @@ -69,7 +69,7 @@ public ActionResult LogIn(string? returnUrl) [HttpPost("~/connect/logout")] [Authorize(AuthenticationSchemes = AuthenticationConstants.CookieAndBearerTokenAuthenticationScheme)] [RequireAntiforgeryToken] - public async Task LogOut(string? returnUrl) + public async Task LogOut(string? returnTo) { // Retrieve the identity stored in the local authentication cookie. If it's not available, // this indicate that the user is already logged out locally (or has not logged in yet). @@ -78,7 +78,7 @@ public async Task LogOut(string? returnUrl) { // Only allow local return URLs to prevent open redirect attacks. // https://learn.microsoft.com/en-us/aspnet/core/security/preventing-open-redirects - return LocalRedirect(SanitizeReturnUrl(returnUrl)); + return LocalRedirect(SanitizeReturnUrl(returnTo)); } // Remove the local authentication cookie before triggering a redirection to the remote server. @@ -98,7 +98,7 @@ public async Task LogOut(string? returnUrl) ) { // Only allow local return URLs to prevent open redirect attacks. - RedirectUri = SanitizeReturnUrl(returnUrl) + RedirectUri = SanitizeReturnUrl(returnTo) }, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme ); @@ -251,11 +251,11 @@ public async Task LogOutCallback(string provider) ); } - private string SanitizeReturnUrl(string? returnUrl) + private string SanitizeReturnUrl(string? returnTo) { return - returnUrl is not null && Url.IsLocalUrl(returnUrl) - ? returnUrl + returnTo is not null && Url.IsLocalUrl(returnTo) + ? returnTo : "/"; } } \ No newline at end of file diff --git a/backend/src/Data/ApplicationDbContext.cs b/backend/src/Data/ApplicationDbContext.cs index 61575e2e..19d9ccd3 100644 --- a/backend/src/Data/ApplicationDbContext.cs +++ b/backend/src/Data/ApplicationDbContext.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using Database.Enumerations; using Database.Extensions; -using Database.GraphQl.Extensions; using GreenDonut.Data; using Laraue.EfCoreTriggers.Common.Extensions; using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; @@ -16,6 +15,7 @@ using SchemaNameOptionsExtension = Database.Data.Extensions.SchemaNameOptionsExtension; using NodaTime; using System.Text.Json; +using Database.GraphQl; namespace Database.Data; @@ -27,6 +27,7 @@ public sealed class ApplicationDbContext { private const string DefaultSchemaName = "database"; private readonly string _schemaName; + private readonly IClock _clock; internal const string CalorimetricObserverTypeName = "calorimetric_observer"; internal const string CoatedSideTypeName = "coated_side"; @@ -38,14 +39,16 @@ public sealed class ApplicationDbContext internal const string StandardizerTypeName = "standardizer"; public ApplicationDbContext( - DbContextOptions options + DbContextOptions options, + IClock clock ) : base(options) { - // The schema-name option is set in `Metabase.Startup` by an invocation of - // `UseSchemaName` on a `DbContextOptionsBuilder` instance. + // The schema-name option is set in `Metabase.Startup` by an invocation + // of `UseSchemaName` on a `DbContextOptionsBuilder` instance. var schemaNameOptions = options.FindExtension(); _schemaName = schemaNameOptions is null ? DefaultSchemaName : schemaNameOptions.SchemaName; + _clock = clock; } // https://docs.microsoft.com/en-us/ef/core/miscellaneous/nullable-reference-types#dbcontext-and-dbset @@ -118,6 +121,53 @@ public OffsetDateTimeUtcValueConverter() } } + public override int SaveChanges() + { + UpdateTimestamps(); + return base.SaveChanges(); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + UpdateTimestamps(); + return base.SaveChangesAsync(cancellationToken); + } + + private void UpdateTimestamps() + { + var entries = ChangeTracker + .Entries() + .Where(_ => + _.State == EntityState.Added + || _.State == EntityState.Modified + // || _.State == EntityState.Deleted + ); + var now = _clock.GetUtcNow().ToDateTimeOffset(); + foreach (var entry in entries) + { + switch (entry.State) + { + case EntityState.Added: + if (entry.Entity.CreatedAt == default) + { + entry.Entity.CreatedAt = now; + } + entry.Entity.UpdatedAt = now; + break; + case EntityState.Modified: + entry.Entity.UpdatedAt = now; + break; + // NOTE that soft deletes do not cascade + // case EntityState.Deleted: + // // soft delete + // entry.State = EntityState.Modified; + // entry.Entity.DeletedAt = now; + // entry.Entity.UpdatedAt = now; + // break; + } + } + } + public IQueryable Data(DataKind dataKind) { return dataKind switch @@ -144,38 +194,38 @@ QueryContext queryContext { return ( CalorimetricData.AsQueryable() - .With(queryContext, sort => sort.StabilizeOrder()) .Where(where) + .With(queryContext, Sorting.DefaultEntityOrder) .ToAsyncEnumerable() ) .Union( GeometricData.AsQueryable() - .With(queryContext, sort => sort.StabilizeOrder()) .Where(where) + .With(queryContext, Sorting.DefaultEntityOrder) .ToAsyncEnumerable() ) .Union( HygrothermalData.AsQueryable() - .With(queryContext, sort => sort.StabilizeOrder()) .Where(where) + .With(queryContext, Sorting.DefaultEntityOrder) .ToAsyncEnumerable() ) .Union( LifeCycleData.AsQueryable() - .With(queryContext, sort => sort.StabilizeOrder()) .Where(where) + .With(queryContext, Sorting.DefaultEntityOrder) .ToAsyncEnumerable() ) .Union( OpticalData.AsQueryable() - .With(queryContext, sort => sort.StabilizeOrder()) .Where(where) + .With(queryContext, Sorting.DefaultEntityOrder) .ToAsyncEnumerable() ) .Union( PhotovoltaicData.AsQueryable() - .With(queryContext, sort => sort.StabilizeOrder()) .Where(where) + .With(queryContext, Sorting.DefaultEntityOrder) .ToAsyncEnumerable() ); } @@ -515,5 +565,63 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() ) .ToTable("institution_access_rights"); + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (typeof(IEntity).IsAssignableFrom(entityType.ClrType)) + { + var entity = modelBuilder.Entity(entityType.ClrType); + // https://www.npgsql.org/efcore/modeling/generated-properties.html#guiduuid-generation + entity + .Property(nameof(IEntity.Id)) + .HasDefaultValueSql("gen_random_uuid()"); + // https://www.npgsql.org/efcore/modeling/concurrency.html#the-postgresql-xmin-system-column + entity + .Property(nameof(IEntity.Version)) + .IsRowVersion(); + } + if (typeof(IAssociation).IsAssignableFrom(entityType.ClrType)) + { + var association = modelBuilder.Entity(entityType.ClrType); + // https://www.npgsql.org/efcore/modeling/concurrency.html#the-postgresql-xmin-system-column + association + .Property(nameof(IAssociation.Version)) + .IsRowVersion(); + } + if (typeof(IAuditable).IsAssignableFrom(entityType.ClrType)) + { + var auditable = modelBuilder.Entity(entityType.ClrType); + auditable + .Property(nameof(IAuditable.CreatedAt)) + .HasDefaultValueSql("now()"); + auditable + .Property(nameof(IAuditable.UpdatedAt)) + .HasDefaultValueSql("now()"); + // exclude soft-deleted entities with the effect that + // `context..ToList()` only returns rows where + // `DeletedAt` is null and + // `context..IgnoreQueryFilters().ToList()` returns + // all rows + // entity + // .HasQueryFilter((IAuditable _) => _.DeletedAt == null); + } + if (typeof(IEntity).IsAssignableFrom(entityType.ClrType) + && typeof(INamed).IsAssignableFrom(entityType.ClrType)) + { + var entity = modelBuilder.Entity(entityType.ClrType); + // https://www.npgsql.org/efcore/modeling/generated-properties.html#guiduuid-generation + entity + .HasIndex(nameof(INamed.Name), nameof(IEntity.Id)) + .IsUnique(); + } + if (typeof(IEntity).IsAssignableFrom(entityType.ClrType) + && typeof(IAuditable).IsAssignableFrom(entityType.ClrType)) + { + var entity = modelBuilder.Entity(entityType.ClrType); + // https://www.npgsql.org/efcore/modeling/generated-properties.html#guiduuid-generation + entity + .HasIndex(nameof(IAuditable.CreatedAt), nameof(IEntity.Id)) + .IsUnique(); + } + } } } \ No newline at end of file diff --git a/backend/src/Data/Association.cs b/backend/src/Data/Association.cs new file mode 100644 index 00000000..08751fa6 --- /dev/null +++ b/backend/src/Data/Association.cs @@ -0,0 +1,8 @@ +namespace Database.Data; + +public abstract class Association +{ + // Configured via `IsRowVersion` in `ApplicationDbContext` instead of the annotation + // [Timestamp] + public uint Version { get; private set; } // https://www.npgsql.org/efcore/modeling/concurrency.html +} \ No newline at end of file diff --git a/backend/src/Data/AuditableAssociation.cs b/backend/src/Data/AuditableAssociation.cs new file mode 100644 index 00000000..044118cd --- /dev/null +++ b/backend/src/Data/AuditableAssociation.cs @@ -0,0 +1,10 @@ +using System; + +namespace Database.Data; + +public abstract class AuditableAssociation +: Association, IAuditable +{ + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/backend/src/Data/AuditableEntity.cs b/backend/src/Data/AuditableEntity.cs new file mode 100644 index 00000000..c7d71196 --- /dev/null +++ b/backend/src/Data/AuditableEntity.cs @@ -0,0 +1,20 @@ +using System; + +namespace Database.Data; + +public abstract class AuditableEntity +: Entity, IAuditable +{ + public AuditableEntity() + : base() + { + } + + public AuditableEntity(Guid id) + : base(id) + { + } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/backend/src/Data/CalorimetricData.cs b/backend/src/Data/CalorimetricData.cs index eb118c32..26e0d52e 100644 --- a/backend/src/Data/CalorimetricData.cs +++ b/backend/src/Data/CalorimetricData.cs @@ -3,7 +3,6 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Threading.Tasks; using Database.Extractors; -using NodaTime; namespace Database.Data; @@ -18,7 +17,7 @@ public CalorimetricData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt, + DateTimeOffset createdAt, AppliedMethod appliedMethod, double[] gValues, double[] uValues @@ -47,7 +46,7 @@ public CalorimetricData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt, + DateTimeOffset createdAt, double[] gValues, double[] uValues ) : base( diff --git a/backend/src/Data/DataX.cs b/backend/src/Data/DataX.cs index eddc6d40..9076ac90 100644 --- a/backend/src/Data/DataX.cs +++ b/backend/src/Data/DataX.cs @@ -3,21 +3,32 @@ using System.Linq; using System.Threading.Tasks; using Database.Enumerations; -using NodaTime; namespace Database.Data; -public abstract class DataX( - Guid? userId, - string locale, - Guid componentId, - string? name, - string? description, - string[] warnings, - Guid creatorId, - OffsetDateTime createdAt -) : Entity, IData +public abstract class DataX : AuditableEntity, IData { + public DataX( + Guid? userId, + string locale, + Guid componentId, + string? name, + string? description, + string[] warnings, + Guid creatorId, + DateTimeOffset createdAt + ) + { + UserId = userId; + Locale = locale; + ComponentId = componentId; + Name = name; + Description = description; + Warnings = warnings; + CreatorId = creatorId; + CreatedAt = createdAt; + } + protected DataX( Guid? userId, string locale, @@ -26,7 +37,7 @@ protected DataX( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt, + DateTimeOffset createdAt, AppliedMethod appliedMethod ) : this( @@ -49,7 +60,7 @@ public void Update( string? name, string? description, string[] warnings, - OffsetDateTime createdAt, + DateTimeOffset createdAt, Guid creatorId ) { @@ -72,14 +83,13 @@ public void Retract() PublishingState = PublishingState.RETRACTED; } - public Guid? UserId { get; private set; } = userId; - public string Locale { get; private set; } = locale; - public Guid ComponentId { get; private set; } = componentId; - public string? Name { get; private set; } = name; - public string? Description { get; private set; } = description; - public string[] Warnings { get; private set; } = warnings; - public Guid CreatorId { get; private set; } = creatorId; - public OffsetDateTime CreatedAt { get; private set; } = createdAt; + public Guid? UserId { get; private set; } + public string Locale { get; private set; } + public Guid ComponentId { get; private set; } + public string? Name { get; private set; } + public string? Description { get; private set; } + public string[] Warnings { get; private set; } + public Guid CreatorId { get; private set; } public AppliedMethod AppliedMethod { get; private set; } = default!; public ICollection Approvals { get; } = []; diff --git a/backend/src/Data/Entity.cs b/backend/src/Data/Entity.cs index b3c54bfe..d831fdc6 100644 --- a/backend/src/Data/Entity.cs +++ b/backend/src/Data/Entity.cs @@ -1,16 +1,25 @@ using System; -// using System.ComponentModel.DataAnnotations.Schema; - namespace Database.Data; public abstract class Entity : IEntity { - public Guid Id { get; private set; } + public Entity() + { + } + + public Entity(Guid id) + { + Id = id; + } + + public Guid Id { get; init; } // [NotMapped] // public Guid Uuid { get => Id; } + // Configured via `IsRowVersion` in `ApplicationDbContext` instead of the annotation + // [Timestamp] public uint Version { get; private set; } // https://www.npgsql.org/efcore/modeling/concurrency.html } \ No newline at end of file diff --git a/backend/src/Data/GeometricData.cs b/backend/src/Data/GeometricData.cs index 5be4f13c..bb5098d8 100644 --- a/backend/src/Data/GeometricData.cs +++ b/backend/src/Data/GeometricData.cs @@ -3,7 +3,6 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Threading.Tasks; using Database.Extractors; -using NodaTime; namespace Database.Data; @@ -18,7 +17,7 @@ public GeometricData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt, + DateTimeOffset createdAt, AppliedMethod appliedMethod, double[] widths, double[] heights, @@ -48,7 +47,7 @@ public GeometricData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt, + DateTimeOffset createdAt, double[] widths, double[] heights, double[] thicknesses diff --git a/backend/src/Data/GetHttpsResource.cs b/backend/src/Data/GetHttpsResource.cs index a15ace42..e7537619 100644 --- a/backend/src/Data/GetHttpsResource.cs +++ b/backend/src/Data/GetHttpsResource.cs @@ -14,7 +14,7 @@ namespace Database.Data; public sealed class GetHttpsResource -: Entity +: AuditableEntity { public const string FilesDirectoryPath = "./files/"; public const string TableName = "get_https_resource"; diff --git a/backend/src/Data/HygrothermalData.cs b/backend/src/Data/HygrothermalData.cs index 3d33a023..79f79f12 100644 --- a/backend/src/Data/HygrothermalData.cs +++ b/backend/src/Data/HygrothermalData.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Threading.Tasks; -using NodaTime; namespace Database.Data; @@ -17,7 +16,7 @@ public HygrothermalData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt, + DateTimeOffset createdAt, AppliedMethod appliedMethod ) : base( userId, @@ -42,7 +41,7 @@ public HygrothermalData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt + DateTimeOffset createdAt ) : base( userId, locale, diff --git a/backend/src/Data/IAssociation.cs b/backend/src/Data/IAssociation.cs new file mode 100644 index 00000000..a49e56f6 --- /dev/null +++ b/backend/src/Data/IAssociation.cs @@ -0,0 +1,7 @@ +namespace Database.Data; + +public interface IAssociation +{ + // Configured via `[Timestamp]` in `Association` + public uint Version { get; } // https://www.npgsql.org/efcore/modeling/concurrency.html +} \ No newline at end of file diff --git a/backend/src/Data/IAuditable.cs b/backend/src/Data/IAuditable.cs new file mode 100644 index 00000000..2e059fc4 --- /dev/null +++ b/backend/src/Data/IAuditable.cs @@ -0,0 +1,13 @@ +using System; + +namespace Database.Data; + +public interface IAuditable +{ + // TODO Switch to NodaTime `OffsetDateTime` once there is a `ICursorKeySerializer` implementation for it. Then sorting by `CreatedAt` and `UpdatedAt` with pagination will keep working. + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + // soft delete + // public Instant? DeletedAt { get; set; } +} \ No newline at end of file diff --git a/backend/src/Data/IData.cs b/backend/src/Data/IData.cs index 79a242e4..f00832d8 100644 --- a/backend/src/Data/IData.cs +++ b/backend/src/Data/IData.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using Database.Enumerations; using Database.GraphQl; -using NodaTime; namespace Database.Data; @@ -15,7 +14,7 @@ namespace Database.Data; [JsonDerivedType(typeof(LifeCycleData), typeDiscriminator: nameof(LifeCycleData))] [JsonDerivedType(typeof(OpticalData), typeDiscriminator: nameof(OpticalData))] [JsonDerivedType(typeof(PhotovoltaicData), typeDiscriminator: nameof(PhotovoltaicData))] -public interface IData : IEntity +public interface IData : IEntity, IAuditable { public static readonly Guid BedJsonDataFormatId = new("9ca9e8f5-94bf-4fdd-81e3-31a58d7ca708"); @@ -25,7 +24,6 @@ public interface IData : IEntity string? Description { get; } string[] Warnings { get; } Guid CreatorId { get; } - OffsetDateTime CreatedAt { get; } AppliedMethod AppliedMethod { get; } ICollection Approvals { get; } ICollection Resources { get; } @@ -62,7 +60,7 @@ void Update( string? name, string? description, string[] warnings, - OffsetDateTime createdAt, + DateTimeOffset createdAt, Guid creatorId ); diff --git a/backend/src/Data/INamed.cs b/backend/src/Data/INamed.cs new file mode 100644 index 00000000..a4f46fc2 --- /dev/null +++ b/backend/src/Data/INamed.cs @@ -0,0 +1,6 @@ +namespace Database.Data; + +public interface INamed +{ + public string Name { get; } +} \ No newline at end of file diff --git a/backend/src/Data/InstitutionAccessRights.cs b/backend/src/Data/InstitutionAccessRights.cs index 95dc8c2a..10db044f 100644 --- a/backend/src/Data/InstitutionAccessRights.cs +++ b/backend/src/Data/InstitutionAccessRights.cs @@ -16,7 +16,7 @@ public sealed class InstitutionAccessRights( uint? allowedDatasetsPerTime, Duration period ) -: Entity +: AuditableEntity { public Guid InstitutionId { get; set; } = institutionId; public uint? AllowedUserCount { get; set; } = allowedUserCount; @@ -38,7 +38,7 @@ Duration period [Projectable] public bool HasRestrictionsByUser => AllowedUserCount != null; - internal bool IsDataRestrictedByTime(IData dataItem, CacheService cacheService, out string? reason) + internal bool IsDataRestrictedByTime(IData dataItem, IClock clock, CacheService cacheService, out string? reason) { var isRestricted = false; reason = null; @@ -46,7 +46,7 @@ internal bool IsDataRestrictedByTime(IData dataItem, CacheService cacheService, if (AllowedDatasetsPerTime is not null) { var accessesPerPeriod = cacheService.GetOrCreateAccessCountForPeriod(InstitutionId); - if (accessesPerPeriod.StartTime + Period < OffsetDateTime.UtcNow) + if (accessesPerPeriod.StartTime + Period < clock.GetUtcNow()) { if (accessesPerPeriod.Count >= AllowedDatasetsPerTime) { diff --git a/backend/src/Data/LifeCycleData.cs b/backend/src/Data/LifeCycleData.cs index 4290aafc..c5b67c55 100644 --- a/backend/src/Data/LifeCycleData.cs +++ b/backend/src/Data/LifeCycleData.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Threading.Tasks; -using NodaTime; namespace Database.Data; @@ -17,7 +16,7 @@ public LifeCycleData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt, + DateTimeOffset createdAt, AppliedMethod appliedMethod ) : base( userId, @@ -42,7 +41,7 @@ public LifeCycleData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt + DateTimeOffset createdAt ) : base( userId, locale, diff --git a/backend/src/Data/OpticalData.cs b/backend/src/Data/OpticalData.cs index 7f0173c0..9c929b5c 100644 --- a/backend/src/Data/OpticalData.cs +++ b/backend/src/Data/OpticalData.cs @@ -5,7 +5,6 @@ using Database.Enumerations; using Database.Enumerations.DataPoints; using Database.Extractors; -using NodaTime; namespace Database.Data; @@ -20,7 +19,7 @@ public OpticalData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt, + DateTimeOffset createdAt, OpticalComponentType? type, OpticalComponentSubtype? subtype, CoatedSide? coatedSide, @@ -65,7 +64,7 @@ public OpticalData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt, + DateTimeOffset createdAt, OpticalComponentType? type, OpticalComponentSubtype? subtype, CoatedSide? coatedSide, diff --git a/backend/src/Data/PhotovoltaicData.cs b/backend/src/Data/PhotovoltaicData.cs index 41d15961..9ec61855 100644 --- a/backend/src/Data/PhotovoltaicData.cs +++ b/backend/src/Data/PhotovoltaicData.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Threading.Tasks; -using NodaTime; namespace Database.Data; @@ -17,7 +16,7 @@ public PhotovoltaicData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt, + DateTimeOffset createdAt, AppliedMethod appliedMethod ) : base( userId, @@ -42,7 +41,7 @@ public PhotovoltaicData( string? description, string[] warnings, Guid creatorId, - OffsetDateTime createdAt + DateTimeOffset createdAt ) : base( userId, locale, diff --git a/backend/src/Data/User.cs b/backend/src/Data/User.cs index 1ca1b218..858e7a98 100644 --- a/backend/src/Data/User.cs +++ b/backend/src/Data/User.cs @@ -4,7 +4,7 @@ public sealed class User( string subject, string name ) -: Entity +: AuditableEntity { public string Subject { get; private set; } = subject; public string Name { get; private set; } = name; diff --git a/backend/src/Database.csproj b/backend/src/Database.csproj index 27942a7b..baa7653e 100644 --- a/backend/src/Database.csproj +++ b/backend/src/Database.csproj @@ -13,33 +13,33 @@ - - - + - - - - - - - - - - + + + + + + + + + + + - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + @@ -47,25 +47,25 @@ - - - - - - - - - - - - + + + + + + + + + + + + - + diff --git a/backend/src/Extensions/EnumerableExtensions.cs b/backend/src/Extensions/EnumerableExtensions.cs deleted file mode 100644 index c7143af6..00000000 --- a/backend/src/Extensions/EnumerableExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.Contracts; -using System.Linq; - -namespace Database.Extensions; - -public static class EnumerableExtensions -{ - [Pure] - public static IEnumerable NotNull(this IEnumerable enumerable) where T : class - { - return enumerable.Where(item => item is not null).Select(item => item!); - } - - [Pure] - public static IEnumerable NotNull(this IEnumerable enumerable) where T : struct - { - return enumerable.Where(item => item.HasValue).Select(item => item!.Value); - } -} \ No newline at end of file diff --git a/backend/src/Extensions/LinqExtensions.cs b/backend/src/Extensions/LinqExtensions.cs new file mode 100644 index 00000000..346869c1 --- /dev/null +++ b/backend/src/Extensions/LinqExtensions.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Linq; +using System.Linq.Expressions; +using System.Runtime.InteropServices; + +namespace Database.Extensions; + +public enum OrderDirection +{ + ASCENDING, + DESCENDING +} + +public static class LinqExtensions +{ + [Pure] + public static IEnumerable If( + this IEnumerable source, + bool condition, + Func, IEnumerable> transform + ) + { + return condition ? transform(source) : source; + } + + [Pure] + public static IQueryable If( + this IQueryable source, + bool condition, + Func, + IQueryable> transform + ) + { + return condition ? transform(source) : source; + } + + [Pure] + public static List IfList( + this List source, + bool condition, + Func, List> transform + ) + { + return condition ? transform(source) : source; + } + + [Pure] + public static List ToReversed(this List source) + { + var copy = new List(source); + copy.Reverse(); + return copy; + } + + [Pure] + public static T? GetAtOrDefault(this T[] array, int index, T? defaultValue = default) where T : class + { + return (index >= 0 && index < array.Length) ? array[index] : defaultValue; + } + + [Pure] + public static T? GetFirstOrDefault(this T[] array) where T : class + { + return array.Length > 0 ? array[0] : default; + } + + [Pure] + public static T? GetAtOrDefault(this IReadOnlyList list, int index) where T : class + { + return (index >= 0 && index < list.Count) ? list[index] : default; + } + + [Pure] + public static T? GetFirstOrDefault(this IReadOnlyList list) where T : class + { + return list.Count > 0 ? list[0] : default; + } + + [Pure] + public static T? GetLastOrDefault(this IReadOnlyList list) where T : class + { + return list.Count > 0 ? list[^1] : default; + } + + [Pure] + public static IEnumerable NotNull(this IEnumerable enumerable) where T : class + { + return enumerable.Where(item => item is not null).Select(item => item!); + } + + [Pure] + public static IEnumerable NotNull(this IEnumerable enumerable) where T : struct + { + return enumerable.Where(item => item.HasValue).Select(item => item!.Value); + } + + [Pure] + public static IOrderedQueryable OrderByDirection( + this IQueryable source, + Expression> keySelector, + OrderDirection direction + ) + { + return direction is OrderDirection.ASCENDING + ? source.OrderBy(keySelector) + : source.OrderByDescending(keySelector); + } + + + [Pure] + public static IEnumerable Interleave(this IEnumerable> sequences) + { + var enumerators = new LinkedList>(); + try + { + foreach (var sequence in sequences) + { + var enumerator = sequence.GetEnumerator(); + if (enumerator.MoveNext()) + { + enumerators.AddLast(enumerator); + yield return enumerator.Current; + } + else + { + enumerator.Dispose(); + } + } + var node = enumerators.First; + while (node is { Value: var enumerator, Next: var nextNode }) + { + if (enumerator.MoveNext()) + { + yield return enumerator.Current; + } + else + { + enumerators.Remove(node); + enumerator.Dispose(); + } + node = nextNode ?? enumerators.First; + } + } + finally + { + foreach (var enumerator in enumerators) + enumerator.Dispose(); + } + } + + [Pure] + public static IEnumerable Scan( + this IEnumerable source, + TAccumulate seed, + Func function) + { + var accumulate = seed; + foreach (var item in source) + { + (accumulate, var result) = function(accumulate, item); + yield return result; + } + } + + [Pure] + public static List Rotate( + this List list, + Predicate after + ) + where T : class + { + if (list.Count is 0) + { + return list; + } + var afterIndex = list.FindIndex(after); + if (afterIndex is -1) + { + return list; + } + var index = (afterIndex + 1) % list.Count; + var result = new List(list.Count); + var span = CollectionsMarshal.AsSpan(list); + result.AddRange(span.Slice(index)); + result.AddRange(span.Slice(0, index)); + return result; + } +} \ No newline at end of file diff --git a/backend/src/Extensions/NodaTimeExtensions.cs b/backend/src/Extensions/NodaTimeExtensions.cs index 9f5edc65..80d21035 100644 --- a/backend/src/Extensions/NodaTimeExtensions.cs +++ b/backend/src/Extensions/NodaTimeExtensions.cs @@ -4,13 +4,18 @@ namespace Database.Extensions; public static class NodaTimeExtensions { - extension(OffsetDateTime) + public static OffsetDateTime GetUtcNow(this IClock clock) { - public static OffsetDateTime UtcNow => - SystemClock.Instance - .GetCurrentInstant() - .WithOffset(Offset.Zero); + return clock.GetCurrentInstant().WithOffset(Offset.Zero); + } + public static int CompareTo(this OffsetDateTime current, OffsetDateTime other) + { + return OffsetDateTime.Comparer.Instant.Compare(current, other); + } + + extension(OffsetDateTime) + { public static bool operator >(OffsetDateTime x, OffsetDateTime y) { return OffsetDateTime.Comparer.Instant.Compare(x, y) > 0; diff --git a/backend/src/Extensions/StringExtensions.cs b/backend/src/Extensions/StringExtensions.cs index 435228db..5d006340 100644 --- a/backend/src/Extensions/StringExtensions.cs +++ b/backend/src/Extensions/StringExtensions.cs @@ -5,6 +5,21 @@ namespace Database.Extensions; public static class StringExtensions { + public static string FirstCharToLower(this string value) + { + return string.IsNullOrEmpty(value) + || !char.IsLetter(value, 0) + || char.IsLower(value, 0) + ? value + : char.ToLowerInvariant(value[0]) + value[1..]; + } + + public static string? NullIfEmpty(this string value) + => string.IsNullOrEmpty(value) ? null : value; + + public static string? NullIfWhitespace(this string value) + => string.IsNullOrWhiteSpace(value) ? null : value; + public static string Base64Encode(this string plainText) { return Convert.ToBase64String( diff --git a/backend/src/GraphQl/Associations/AuditableAssociationFilterType.cs b/backend/src/GraphQl/Associations/AuditableAssociationFilterType.cs new file mode 100644 index 00000000..70059ad5 --- /dev/null +++ b/backend/src/GraphQl/Associations/AuditableAssociationFilterType.cs @@ -0,0 +1,19 @@ +using HotChocolate.Data.Filters; +using Database.Data; + +namespace Database.GraphQl.Associations; + +public abstract class AuditableAssociationFilterType + : FilterInputType + where TAssociation : IAssociation, IAuditable +{ + protected override void Configure( + IFilterInputTypeDescriptor descriptor + ) + { + base.Configure(descriptor); + descriptor.BindFieldsExplicitly(); + descriptor.Field(x => x.CreatedAt); + descriptor.Field(x => x.UpdatedAt); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/Associations/AuditableAssociationSortType.cs b/backend/src/GraphQl/Associations/AuditableAssociationSortType.cs new file mode 100644 index 00000000..05109771 --- /dev/null +++ b/backend/src/GraphQl/Associations/AuditableAssociationSortType.cs @@ -0,0 +1,19 @@ +using HotChocolate.Data.Sorting; +using Database.Data; + +namespace Database.GraphQl.Associations; + +public abstract class AuditableAssociationSortType + : SortInputType + where TAssociation : IAssociation, IAuditable +{ + protected override void Configure( + ISortInputTypeDescriptor descriptor + ) + { + base.Configure(descriptor); + descriptor.BindFieldsExplicitly(); + descriptor.Field(x => x.CreatedAt); + descriptor.Field(x => x.UpdatedAt); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/AuthorizedConnection.cs b/backend/src/GraphQl/AuthorizedConnection.cs new file mode 100644 index 00000000..9a13a8b6 --- /dev/null +++ b/backend/src/GraphQl/AuthorizedConnection.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using HotChocolate.CostAnalysis.Types; +using Database.Data; + +namespace Database.GraphQl; + +public abstract class AuthorizedConnection( + TSubject subject, + Func createEdge, + Func> isAuthorized, + QueryContext queryContext +) : Connection(subject, createEdge, queryContext) + where TSubject : IEntity + where TAssociationsByOneIdDataLoader : IDataLoader +{ + [Cost(0)] + public async IAsyncEnumerable GetEdgesAsync( + ClaimsPrincipal claimsPrincipal, + TAuthorization authorization, + TAssociationsByOneIdDataLoader dataLoader, + [EnumeratorCancellation] CancellationToken cancellationToken + ) + { + if (!await isAuthorized(claimsPrincipal, Subject, authorization, cancellationToken)) + { + yield break; + } + await foreach (var edge in GetEdgesAsync(dataLoader, cancellationToken)) + { + yield return edge; + } + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/AuthorizedPaginatedConnection.cs b/backend/src/GraphQl/AuthorizedPaginatedConnection.cs new file mode 100644 index 00000000..c418864d --- /dev/null +++ b/backend/src/GraphQl/AuthorizedPaginatedConnection.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using HotChocolate.CostAnalysis.Types; +using Database.Data; + +namespace Database.GraphQl; + +public abstract class AuthorizedPaginatedConnection( + TSubject subject, + Func createEdge, + Func> isAuthorized, + PagingArguments pagingArguments, + QueryContext queryContext +) : PaginatedConnection( + subject, + createEdge, + pagingArguments, + queryContext +) + where TSubject : IEntity + where TAssociation : class + where TAssociationsByOneIdDataLoader : IDataLoader> +{ + [Cost(0)] + public async IAsyncEnumerable GetEdgesAsync( + ClaimsPrincipal claimsPrincipal, + TAuthorization authorization, + TAssociationsByOneIdDataLoader dataLoader, + [EnumeratorCancellation] CancellationToken cancellationToken + ) + { + if (!await isAuthorized(claimsPrincipal, authorization, cancellationToken)) + { + yield break; + } + await foreach (var edge in base.GetEdgesAsync(dataLoader, cancellationToken)) + { + yield return edge; + } + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataByIdDataLoader.cs b/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataByIdDataLoader.cs deleted file mode 100644 index 52cbce26..00000000 --- a/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataByIdDataLoader.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.CalorimetricDataX; - -public sealed class CalorimetricDataByIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : EntityByIdDataLoader( - batchScheduler, - options, - dbContextFactory, - dbContext => dbContext.CalorimetricData - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataLoaders.cs b/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataLoaders.cs new file mode 100644 index 00000000..eea3cf77 --- /dev/null +++ b/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataLoaders.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace Database.GraphQl.CalorimetricDataX; + +public sealed class CalorimetricDataLoaders +: DataLoaders +{ + [DataLoader] + public static ValueTask> GetCalorimetricDataByIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetEntityByIdAsync( + ids, + databaseContext => databaseContext.CalorimetricData, + queryContext, + databaseContextFactory, + cancellationToken + ); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataQueries.cs b/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataQueries.cs index 3bd1573b..99684588 100644 --- a/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataQueries.cs +++ b/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataQueries.cs @@ -1,14 +1,13 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Database.Authorization; using Database.Data; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; using HotChocolate; using HotChocolate.Data; -using HotChocolate.Data.Sorting; using HotChocolate.Resolvers; using HotChocolate.Types; @@ -19,15 +18,12 @@ public sealed class CalorimetricDataQueries : DataQueriesBase { [UsePaging] - // [UseProjection] // We disabled projections because when requesting `id` all results had the - // same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public Task> GetAllCalorimetricDataAsync( + public Task> GetAllCalorimetricDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, - ISortingContext sorting, IResolverContext resolverContext, CancellationToken cancellationToken ) @@ -36,7 +32,6 @@ CancellationToken cancellationToken context.CalorimetricData, locale, accessRightsService, - sorting, resolverContext, cancellationToken ); @@ -47,11 +42,10 @@ CancellationToken cancellationToken // same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public Task> GetAllPendingCalorimetricDataAsync( + public Task> GetAllPendingCalorimetricDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, - ISortingContext sorting, IResolverContext resolverContext, CommonAuthorization authorization, CancellationToken cancellationToken @@ -61,7 +55,6 @@ CancellationToken cancellationToken context.CalorimetricData, locale, accessRightsService, - sorting, resolverContext, authorization, cancellationToken diff --git a/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataType.cs b/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataType.cs index 6951cdf9..f395b413 100644 --- a/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataType.cs +++ b/backend/src/GraphQl/CalorimetricDataX/CalorimetricDataType.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.CalorimetricDataX; public sealed class CalorimetricDataType - : DataTypeBase + : DataTypeBase { protected override void Configure( IObjectTypeDescriptor descriptor diff --git a/backend/src/GraphQl/CalorimetricDataX/CreateCalorimetricDataMutation.cs b/backend/src/GraphQl/CalorimetricDataX/CreateCalorimetricDataMutation.cs index 5fa7bbbb..1c84da8e 100644 --- a/backend/src/GraphQl/CalorimetricDataX/CreateCalorimetricDataMutation.cs +++ b/backend/src/GraphQl/CalorimetricDataX/CreateCalorimetricDataMutation.cs @@ -8,6 +8,7 @@ using Database.Data; using Database.Extensions; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; using HotChocolate; using HotChocolate.Types; @@ -21,7 +22,7 @@ public sealed record CreateCalorimetricDataInput( string? Name, string? Description, string[] Warnings, - OffsetDateTime CreatedAt, + DateTimeOffset CreatedAt, Guid CreatorId, AppliedMethodInput AppliedMethod, RootGetHttpsResourceInput RootResource, @@ -106,6 +107,7 @@ public async Task CreateCalorimetricDataAsync( IDataByDatabaseAndIdAndKindDataLoader dataByDatabaseAndIdAndKindDataLoader, IDataFormatByIdDataLoader dataFormatByIdDataLoader, ResponseApprovalService responseApprovalService, + IClock clock, CancellationToken cancellationToken ) { @@ -135,6 +137,7 @@ CancellationToken cancellationToken CreateCalorimetricDataErrorCode.UNKNOWN_DATA, dataFormatByIdDataLoader, CreateCalorimetricDataErrorCode.UNKNOWN_DATA_FORMAT, + clock, cancellationToken ) ).Failed(out var dataFormat, out var validateErrorPayload) @@ -167,4 +170,4 @@ CancellationToken cancellationToken return NewPayload(calorimetricData, null); } -} +} \ No newline at end of file diff --git a/backend/src/GraphQl/Connection.cs b/backend/src/GraphQl/Connection.cs index 595ae887..7d737d3b 100644 --- a/backend/src/GraphQl/Connection.cs +++ b/backend/src/GraphQl/Connection.cs @@ -4,41 +4,48 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -using Database.Data; using GreenDonut; using GreenDonut.Data; +using HotChocolate.CostAnalysis.Types; +using Database.Data; namespace Database.GraphQl; -public abstract class Connection( +public abstract class Connection( TSubject subject, Func createEdge, QueryContext queryContext - ) +) where TSubject : IEntity - where TAssociationsByAssociateIdDataLoader : IDataLoader + where TAssociationsByOneIdDataLoader : IDataLoader { - private readonly Func _createEdge = createEdge; - private readonly QueryContext _queryContext = queryContext; - protected TSubject Subject { get; } = subject; + [Cost(0)] public async Task GetTotalCountAsync( - TAssociationsByAssociateIdDataLoader dataLoader, + TAssociationsByOneIdDataLoader dataLoader, CancellationToken cancellationToken ) { - return (uint)(await dataLoader.With(_queryContext).LoadRequiredAsync(Subject.Id, cancellationToken)).Length; + return (uint)( + ( + await dataLoader + .With(queryContext) + .LoadAsync(Subject.Id, cancellationToken) + ) + ?.Length ?? 0 + ); } + [Cost(0)] public async IAsyncEnumerable GetEdgesAsync( - TAssociationsByAssociateIdDataLoader dataLoader, + TAssociationsByOneIdDataLoader dataLoader, [EnumeratorCancellation] CancellationToken cancellationToken ) { - foreach (var association in await dataLoader.With(_queryContext).LoadRequiredAsync(Subject.Id, cancellationToken)) + foreach (var association in await dataLoader.With(queryContext).LoadAsync(Subject.Id, cancellationToken) ?? []) { - yield return _createEdge(association); + yield return createEdge(association); } } } \ No newline at end of file diff --git a/backend/src/GraphQl/DataLoaders.cs b/backend/src/GraphQl/DataLoaders.cs new file mode 100644 index 00000000..e3f59548 --- /dev/null +++ b/backend/src/GraphQl/DataLoaders.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut.Data; +using LinqKit; +using Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace Database.GraphQl; + +public abstract class DataLoaders +{ + public static async ValueTask> GetEntityByIdAsync + ( + IReadOnlyList ids, + Func> getEntities, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + where TEntity : class, IEntity, IAuditable + { + await using var databaseContext = + databaseContextFactory.CreateDbContext(); + return await getEntities(databaseContext) + .AsNoTrackingWithIdentityResolution() + .Where(_ => ids.Contains(_.Id)) + .With(queryContext, Sorting.DefaultEntityOrder) + .ToDictionaryAsync(_ => _.Id, cancellationToken); + } + + public static async ValueTask> GetManyByOneIdAsync( + IReadOnlyList ids, + Func> getMany, + Expression> getOneId, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + where TMany : class, IEntity, IAuditable + { + await using var databaseContext = + databaseContextFactory.CreateDbContext(); + return + await getMany(databaseContext) + .AsExpandable() + .AsNoTracking() + .Where(_ => ids.Contains(getOneId.Invoke(_))) + .With(queryContext, Sorting.DefaultEntityOrder) + .GroupBy(getOneId) + .Select(_ => new { _.Key, Items = _.ToArray() }) + .ToDictionaryAsync(_ => _.Key, _ => _.Items, cancellationToken); + } + + public static async ValueTask>> GetManyByOneIdAsync( + IReadOnlyList ids, + Func> getMany, + Expression> getOneId, + PagingArguments pagingArguments, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + where TMany : class, IEntity, IAuditable + { + await using var databaseContext = + databaseContextFactory.CreateDbContext(); + return + await getMany(databaseContext) + .AsExpandable() + .AsNoTracking() + .Where(_ => ids.Contains(getOneId.Invoke(_))) + .With(queryContext, Sorting.DefaultEntityOrder) + .ToBatchPageAsync(getOneId, pagingArguments, cancellationToken); + } + + public static async ValueTask> GetAssociationsByOneIdAsync( + IReadOnlyList ids, + Func> getAssociations, + Expression> getOneId, + Expression> getOtherId, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + where TAssociation : class, IAssociation, IAuditable + { + await using var databaseContext = + databaseContextFactory.CreateDbContext(); + return + await getAssociations(databaseContext) + .AsExpandable() + .AsNoTracking() + .Where(_ => ids.Contains(getOneId.Invoke(_))) + .With(queryContext, _ => _.AddDescending(getOneId).AddDescending(getOtherId)) + .GroupBy(getOneId) + .Select(_ => new { _.Key, Items = _.ToArray() }) + .ToDictionaryAsync(_ => _.Key, _ => _.Items, cancellationToken); + } + + public static async ValueTask>> GetAssociationsByOneIdAsync( + IReadOnlyList ids, + Func> getAssociations, + Expression> getOneId, + Expression> getOtherId, + PagingArguments pagingArguments, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + where TAssociation : class, IAssociation, IAuditable + { + await using var databaseContext = + databaseContextFactory.CreateDbContext(); + return + await getAssociations(databaseContext) + .AsExpandable() + .AsNoTracking() + .Where(_ => ids.Contains(getOneId.Invoke(_))) + .With(queryContext, _ => _.AddDescending(getOneId).AddDescending(getOtherId)) + .ToBatchPageAsync(getOneId, pagingArguments, cancellationToken); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/DataX/CreateDataMutationBase.cs b/backend/src/GraphQl/DataX/CreateDataMutationBase.cs index 378f9927..7471b6c8 100644 --- a/backend/src/GraphQl/DataX/CreateDataMutationBase.cs +++ b/backend/src/GraphQl/DataX/CreateDataMutationBase.cs @@ -16,7 +16,7 @@ namespace Database.GraphQl.DataX; public interface IValidateCreateInput { Guid ComponentId { get; } - OffsetDateTime CreatedAt { get; } + DateTimeOffset CreatedAt { get; } Guid CreatorId { get; } AppliedMethodInput AppliedMethod { get; } RootGetHttpsResourceInput RootResource { get; } @@ -42,6 +42,7 @@ protected async Task> ValidateAsync( TErrorCode unknownCrossDatabaseData, IDataFormatByIdDataLoader dataFormatByIdDataLoader, TErrorCode unknownDataFormatErrorCode, + IClock clock, CancellationToken cancellationToken ) { @@ -56,7 +57,7 @@ CancellationToken cancellationToken ) ); } - if (input.CreatedAt > OffsetDateTime.UtcNow) + if (input.CreatedAt > clock.GetUtcNow().ToDateTimeOffset()) { errors.Add( NewError( diff --git a/backend/src/GraphQl/DataX/DataConnection.cs b/backend/src/GraphQl/DataX/DataConnection.cs index a6e4b206..23315c1f 100644 --- a/backend/src/GraphQl/DataX/DataConnection.cs +++ b/backend/src/GraphQl/DataX/DataConnection.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; using HotChocolate.Types.Pagination; namespace Database.GraphQl.DataX; @@ -9,11 +7,11 @@ public sealed class DataConnection( IReadOnlyList edges, uint totalCount, ConnectionPageInfo pageInfo - ) - : DataConnectionBase( - edges, - totalCount, - pageInfo - ) +) +: DataConnectionBase( + edges, + totalCount, + pageInfo +) { } \ No newline at end of file diff --git a/backend/src/GraphQl/DataX/DataConnectionBase.cs b/backend/src/GraphQl/DataX/DataConnectionBase.cs index ae976dd8..2d7a0aa0 100644 --- a/backend/src/GraphQl/DataX/DataConnectionBase.cs +++ b/backend/src/GraphQl/DataX/DataConnectionBase.cs @@ -1,7 +1,6 @@ -using System; using System.Collections.Generic; +using Database.GraphQl.Scalars; using HotChocolate; -using HotChocolate.Types; using HotChocolate.Types.Pagination; namespace Database.GraphQl.DataX; @@ -10,7 +9,7 @@ public abstract class DataConnectionBase( IReadOnlyList edges, uint totalCount, ConnectionPageInfo pageInfo - ) +) { public IReadOnlyList Edges { get; } = edges; diff --git a/backend/src/GraphQl/DataX/DataDataLoaders.cs b/backend/src/GraphQl/DataX/DataDataLoaders.cs new file mode 100644 index 00000000..26ea47c6 --- /dev/null +++ b/backend/src/GraphQl/DataX/DataDataLoaders.cs @@ -0,0 +1,69 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace Database.GraphQl.DataX; + +public sealed class DataDataLoaders +: DataLoaders +{ + [DataLoader] + public static ValueTask> GetHttpsResourcesByDataIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetManyByOneIdAsync( + ids, + (databaseContext) => databaseContext.GetHttpsResources, + _ => _.DataId, + queryContext, + databaseContextFactory, + cancellationToken + ); + } + + [DataLoader] + public static ValueTask> GetHttpsResourceTreeNonRootVerticesByDataIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetManyByOneIdAsync( + ids, + (databaseContext) => databaseContext.GetHttpsResources.Where(_ => _.ParentId != null), + _ => _.DataId, + queryContext, + databaseContextFactory, + cancellationToken + ); + } + + [DataLoader] + public static ValueTask> GetHttpsResourceTreeRootByDataIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetManyByOneIdAsync( + ids, + (databaseContext) => databaseContext.GetHttpsResources.Where(_ => _.ParentId == null), + _ => _.DataId, + queryContext, + databaseContextFactory, + cancellationToken + ); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/DataX/DataFilterTypeBase.cs b/backend/src/GraphQl/DataX/DataFilterTypeBase.cs index 7f56ab86..001f734f 100644 --- a/backend/src/GraphQl/DataX/DataFilterTypeBase.cs +++ b/backend/src/GraphQl/DataX/DataFilterTypeBase.cs @@ -5,8 +5,8 @@ namespace Database.GraphQl.DataX; public abstract class DataFilterTypeBase - : EntityFilterType - where TData : IData + : AuditableEntityFilterType + where TData : IData, IAuditable { protected override void Configure( IFilterInputTypeDescriptor descriptor @@ -19,7 +19,6 @@ IFilterInputTypeDescriptor descriptor descriptor.Field(x => x.Description); descriptor.Field(x => x.ComponentId); descriptor.Field(x => x.CreatorId); - descriptor.Field(x => x.CreatedAt); descriptor.Field(x => x.AppliedMethod); descriptor.Field(x => x.Approvals); descriptor.Field(x => x.Resources); diff --git a/backend/src/GraphQl/DataX/DataQueries.cs b/backend/src/GraphQl/DataX/DataQueries.cs index 6885c5fe..d463ec65 100644 --- a/backend/src/GraphQl/DataX/DataQueries.cs +++ b/backend/src/GraphQl/DataX/DataQueries.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Database.Data; using Database.Enumerations; +using Database.GraphQl.Scalars; using Database.Services; using HotChocolate; using HotChocolate.Types; diff --git a/backend/src/GraphQl/DataX/DataQueriesBase.cs b/backend/src/GraphQl/DataX/DataQueriesBase.cs index 216b0ec6..17d29e04 100644 --- a/backend/src/GraphQl/DataX/DataQueriesBase.cs +++ b/backend/src/GraphQl/DataX/DataQueriesBase.cs @@ -1,16 +1,15 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Database.Authorization; using Database.Data; -using Database.GraphQl.Entities; using Database.GraphQl.Extensions; +using Database.GraphQl.Scalars; using Database.Services; +using GreenDonut.Data; using HotChocolate; using HotChocolate.Data; -using HotChocolate.Data.Sorting; using HotChocolate.Resolvers; using Microsoft.EntityFrameworkCore; @@ -19,33 +18,31 @@ namespace Database.GraphQl.DataX; public abstract class DataQueriesBase where TData : class, IData { - protected async Task> GetAllDataAsync( + protected async Task> GetAllDataAsync( DbSet data, [GraphQLType] string? locale, AccessRightsService accessRightsService, - ISortingContext sorting, IResolverContext resolverContext, CancellationToken cancellationToken ) { - sorting.StabilizeOrder(); - var filteredData = + var sortedAndFilteredData = data.AsNoTracking() .Where(_ => _.PublishingState != Enumerations.PublishingState.PENDING) - .Sort(resolverContext) - .Filter(resolverContext); - if (!await filteredData.AnyAsync(x => x.DataAccessRights.HasRestrictions, cancellationToken)) + .With(resolverContext.GetQueryContext(), Sorting.DefaultEntityOrder); + if (await sortedAndFilteredData.AnyAsync(_ => _.DataAccessRights.HasRestrictions, cancellationToken)) { - return filteredData; + sortedAndFilteredData = (await accessRightsService.ApplyAccessRightsOnData(sortedAndFilteredData, cancellationToken)).AsQueryable(); } - return await accessRightsService.ApplyAccessRightsOnData(filteredData, cancellationToken); + return await sortedAndFilteredData + .ToPageAsync(resolverContext.GetPagingArguments(), cancellationToken) + .ToConnectionAsync(); } - protected async Task> GetAllPendingDataAsync( + protected async Task> GetAllPendingDataAsync( DbSet data, [GraphQLType] string? locale, AccessRightsService accessRightsService, - ISortingContext sorting, IResolverContext resolverContext, CommonAuthorization authorization, CancellationToken cancellationToken @@ -53,19 +50,22 @@ CancellationToken cancellationToken { if (!await authorization.IsDatabaseOperator(cancellationToken)) { - return Enumerable.Empty().AsQueryable(); + return await Enumerable.Empty() + .AsQueryable() + .ToPageAsync(resolverContext.GetPagingArguments(), cancellationToken) + .ToConnectionAsync(); } - sorting.StabilizeOrder(); - var filteredData = + var sortedAndFilteredData = data.AsNoTracking() .Where(_ => _.PublishingState == Enumerations.PublishingState.PENDING) - .Sort(resolverContext) - .Filter(resolverContext); - if (!await filteredData.AnyAsync(x => x.DataAccessRights.HasRestrictions, cancellationToken)) + .With(resolverContext.GetQueryContext(), Sorting.DefaultEntityOrder); + if (await sortedAndFilteredData.AnyAsync(_ => _.DataAccessRights.HasRestrictions, cancellationToken)) { - return filteredData; + sortedAndFilteredData = (await accessRightsService.ApplyAccessRightsOnData(sortedAndFilteredData, cancellationToken)).AsQueryable(); } - return await accessRightsService.ApplyAccessRightsOnData(filteredData, cancellationToken); + return await sortedAndFilteredData + .ToPageAsync(resolverContext.GetPagingArguments(), cancellationToken) + .ToConnectionAsync(); } protected Task HasDataAsync( @@ -77,14 +77,14 @@ CancellationToken cancellationToken { return data.AsNoTracking() .Where(_ => _.PublishingState != Enumerations.PublishingState.PENDING) - .Filter(resolverContext) + .With(resolverContext.GetQueryContext(), Sorting.DefaultEntityOrder) .AnyAsync(cancellationToken); } protected async Task GetDataAsync( Guid id, [GraphQLType] string? locale, - EntityByIdDataLoader byId, + GreenDonut.DataLoaderBase byId, AccessRightsService accessRightsService, CancellationToken cancellationToken ) diff --git a/backend/src/GraphQl/DataX/DataResolvers.cs b/backend/src/GraphQl/DataX/DataResolvers.cs index 68ab7997..a605d4fe 100644 --- a/backend/src/GraphQl/DataX/DataResolvers.cs +++ b/backend/src/GraphQl/DataX/DataResolvers.cs @@ -1,8 +1,6 @@ -using System; using System.Threading; using System.Threading.Tasks; using Database.Data; -using Database.Extensions; using Database.GraphQl.Extensions; using Database.GraphQl.GetHttpsResources; using GreenDonut; @@ -10,7 +8,6 @@ using HotChocolate; using HotChocolate.Data; using HotChocolate.Resolvers; -using NodaTime; namespace Database.GraphQl.DataX; @@ -18,29 +15,22 @@ public sealed class DataResolvers { [UseFiltering] [UseSorting] - public async Task GetGetHttpsResources( + public Task GetHttpsResources( [Parent] IData data, IResolverContext resolverContext, - GetHttpsResourcesByDataIdDataLoader byId, + IHttpsResourcesByDataIdDataLoader byId, CancellationToken cancellationToken ) { - var queryContext = resolverContext.GetQueryContext(); - return await - byId - .With(queryContext) + return byId + .With(resolverContext.GetQueryContext()) .LoadRequiredAsync(data.Id, cancellationToken); } - public GetHttpsResourceTree GetGetHttpsResourceTree( + public GetHttpsResourceTree GetHttpsResourceTree( [Parent] IData data ) { return new GetHttpsResourceTree(data); } - - public OffsetDateTime GetTimestamp() - { - return OffsetDateTime.UtcNow; - } } \ No newline at end of file diff --git a/backend/src/GraphQl/DataX/DataSortTypeBase.cs b/backend/src/GraphQl/DataX/DataSortTypeBase.cs index 880630de..c5e92791 100644 --- a/backend/src/GraphQl/DataX/DataSortTypeBase.cs +++ b/backend/src/GraphQl/DataX/DataSortTypeBase.cs @@ -5,8 +5,8 @@ namespace Database.GraphQl.DataX; public abstract class DataSortTypeBase - : EntitySortType - where TData : IData + : AuditableEntitySortType + where TData : IData, IAuditable { protected override void Configure( ISortInputTypeDescriptor descriptor @@ -18,7 +18,6 @@ ISortInputTypeDescriptor descriptor descriptor.Field(x => x.Description); descriptor.Field(x => x.ComponentId); descriptor.Field(x => x.CreatorId); - descriptor.Field(x => x.CreatedAt); descriptor.Field(x => x.AppliedMethod); } } \ No newline at end of file diff --git a/backend/src/GraphQl/DataX/DataType.cs b/backend/src/GraphQl/DataX/DataType.cs index 0af49f42..101b46ba 100644 --- a/backend/src/GraphQl/DataX/DataType.cs +++ b/backend/src/GraphQl/DataX/DataType.cs @@ -1,4 +1,5 @@ using Database.Data; +using Database.GraphQl.Scalars; using HotChocolate.Types; namespace Database.GraphQl.DataX; @@ -16,27 +17,27 @@ IInterfaceTypeDescriptor descriptor { // `1..` is a range as introduced in https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-8#indices-and-ranges descriptor.Name(nameof(IData)[1..]); + descriptor + .Field(_ => _.PublishingState) + .Ignore(); descriptor .Field(GraphQlConstants.UuidFieldName) .Type>(); + descriptor + .Field(_ => _.UpdatedAt) + .Name(TimestampFieldName); + descriptor + .Field(x => x.Locale) + .Type>(); descriptor .Field(DatabaseIdFieldName) .Type>() .Resolve(_ => appSettings.DatabaseId); - descriptor - .Field(TimestampFieldName) - .Type>(); descriptor .Field(ResourceTreeFieldName) .Type>>(); - descriptor - .Field(x => x.Locale) - .Type>(); descriptor .Field(x => x.Approval) .Type>>(); - descriptor - .Field(_ => _.PublishingState) - .Ignore(); } } \ No newline at end of file diff --git a/backend/src/GraphQl/DataX/DataTypeBase.cs b/backend/src/GraphQl/DataX/DataTypeBase.cs index 4c8373ef..bcfcda17 100644 --- a/backend/src/GraphQl/DataX/DataTypeBase.cs +++ b/backend/src/GraphQl/DataX/DataTypeBase.cs @@ -1,5 +1,7 @@ using System; using Database.Data; +using Database.GraphQl.Entities; +using Database.GraphQl.Scalars; using GreenDonut; using HotChocolate.Types; @@ -8,31 +10,30 @@ namespace Database.GraphQl.DataX; public abstract class DataTypeBase : EntityType where TData : IData - where TDataByIdDataLoader : IDataLoader + where TDataByIdDataLoader : IDataLoader { protected override void Configure( IObjectTypeDescriptor descriptor ) { base.Configure(descriptor); + descriptor + .Field(_ => _.PublishingState) + .Ignore(); + descriptor + .Field(_ => _.UpdatedAt) + .Name(DataType.TimestampFieldName); descriptor .Field(x => x.Locale) .Type>(); descriptor .Field(x => x.Resources) - .ResolveWith(t => t.GetGetHttpsResources(default!, default!, default!, default!)); + .ResolveWith(t => t.GetHttpsResources(default!, default!, default!, default!)); descriptor .Field(DataType.ResourceTreeFieldName) - .ResolveWith(t => t.GetGetHttpsResourceTree(default!)); - descriptor - .Field(DataType.TimestampFieldName) - .Type>() - .ResolveWith(t => t.GetTimestamp()); + .ResolveWith(t => t.GetHttpsResourceTree(default!)); descriptor .Field(x => x.Approval) .Type>>(); - descriptor - .Field(_ => _.PublishingState) - .Ignore(); } } \ No newline at end of file diff --git a/backend/src/GraphQl/DataX/GetHttpsResourceTree.cs b/backend/src/GraphQl/DataX/GetHttpsResourceTree.cs index 5751c639..76c9c24a 100644 --- a/backend/src/GraphQl/DataX/GetHttpsResourceTree.cs +++ b/backend/src/GraphQl/DataX/GetHttpsResourceTree.cs @@ -16,7 +16,7 @@ Data.IData data ) { public async Task GetRoot( - GetHttpsResourceTreeRootByDataIdDataLoader byId, + IHttpsResourceTreeRootByDataIdDataLoader byId, CancellationToken cancellationToken ) { @@ -30,14 +30,13 @@ CancellationToken cancellationToken [UseSorting] public async Task> GetNonRootVertices( IResolverContext resolverContext, - GetHttpsResourceTreeNonRootVerticesByDataIdDataLoader byId, + IHttpsResourceTreeNonRootVerticesByDataIdDataLoader byId, CancellationToken cancellationToken ) { - var queryContext = resolverContext.GetQueryContext(); return ( await byId - .With(queryContext) + .With(resolverContext.GetQueryContext()) .LoadRequiredAsync(data.Id, cancellationToken) ) .Select(v => diff --git a/backend/src/GraphQl/DataX/GetHttpsResourceTreeNonRootVerticesByDataIdDataLoader.cs b/backend/src/GraphQl/DataX/GetHttpsResourceTreeNonRootVerticesByDataIdDataLoader.cs deleted file mode 100644 index 51446c32..00000000 --- a/backend/src/GraphQl/DataX/GetHttpsResourceTreeNonRootVerticesByDataIdDataLoader.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Linq; -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.DataX; - -public sealed class GetHttpsResourceTreeNonRootVerticesByDataIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : AssociationsByAssociateIdDataLoader( - batchScheduler, - options, - dbContextFactory, - (dbContext, ids) => - dbContext.GetHttpsResources.AsNoTracking().Where(x => - x.ParentId != null && ids.Contains( - x.CalorimetricDataId - ?? x.GeometricDataId - ?? x.HygrothermalDataId - ?? x.LifeCycleDataId - ?? x.OpticalDataId - ?? x.PhotovoltaicDataId - ?? Guid.Empty - ) - ), - x => x.DataId - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/DataX/GetHttpsResourceTreeRootByDataIdDataLoader.cs b/backend/src/GraphQl/DataX/GetHttpsResourceTreeRootByDataIdDataLoader.cs deleted file mode 100644 index 07fe5492..00000000 --- a/backend/src/GraphQl/DataX/GetHttpsResourceTreeRootByDataIdDataLoader.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Linq; -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.DataX; - -public sealed class GetHttpsResourceTreeRootByDataIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : AssociationsByAssociateIdDataLoader( - batchScheduler, - options, - dbContextFactory, - (dbContext, ids) => - dbContext.GetHttpsResources.AsNoTracking().Where(x => - x.ParentId == null && ids.Contains( - x.CalorimetricDataId - ?? x.GeometricDataId - ?? x.HygrothermalDataId - ?? x.LifeCycleDataId - ?? x.OpticalDataId - ?? x.PhotovoltaicDataId - ?? Guid.Empty - ) - ), - x => x.DataId - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/DataX/GetHttpsResourcesByDataIdDataLoader.cs b/backend/src/GraphQl/DataX/GetHttpsResourcesByDataIdDataLoader.cs deleted file mode 100644 index 0882dc0c..00000000 --- a/backend/src/GraphQl/DataX/GetHttpsResourcesByDataIdDataLoader.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Linq; -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.DataX; - -public sealed class GetHttpsResourcesByDataIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : AssociationsByAssociateIdDataLoader( - batchScheduler, - options, - dbContextFactory, - (dbContext, ids) => - dbContext.GetHttpsResources.AsNoTracking().Where(x => - ids.Contains( - x.CalorimetricDataId - ?? x.GeometricDataId - ?? x.HygrothermalDataId - ?? x.LifeCycleDataId - ?? x.OpticalDataId - ?? x.PhotovoltaicDataId - ?? Guid.Empty - ) - ), - x => x.DataId - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/DataX/UpdateDataMutation.cs b/backend/src/GraphQl/DataX/UpdateDataMutation.cs index f9f5ae1b..7fc0321c 100644 --- a/backend/src/GraphQl/DataX/UpdateDataMutation.cs +++ b/backend/src/GraphQl/DataX/UpdateDataMutation.cs @@ -5,12 +5,12 @@ using System.Threading.Tasks; using Database.Authorization; using Database.Data; +using Database.GraphQl.Scalars; using Database.Enumerations; using Database.Extensions; using Database.Services; using HotChocolate; using HotChocolate.Types; -using NodaTime; namespace Database.GraphQl.DataX; @@ -22,7 +22,7 @@ public sealed record UpdateDataInput( string? Name, string? Description, string[] Warnings, - OffsetDateTime CreatedAt, + DateTimeOffset CreatedAt, Guid CreatorId ) : IIdentifyDataInput; diff --git a/backend/src/GraphQl/Entities/AssociationsByAssociateIdDataLoader.cs b/backend/src/GraphQl/Entities/AssociationsByAssociateIdDataLoader.cs deleted file mode 100644 index 5efb927d..00000000 --- a/backend/src/GraphQl/Entities/AssociationsByAssociateIdDataLoader.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Database.Data; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.Entities; - -public abstract class AssociationsByAssociateIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory, - Func, IQueryable> getAssociations, - Func getAssociateId - ) - : GroupedDataLoader(batchScheduler, options) -{ - private readonly IDbContextFactory _dbContextFactory = dbContextFactory; - private readonly Func _getAssociateId = getAssociateId; - private readonly Func, IQueryable> _getAssociations = getAssociations; - - protected override async Task> LoadGroupedBatchAsync( - IReadOnlyList keys, - CancellationToken cancellationToken - ) - { - await using var dbContext = - _dbContextFactory.CreateDbContext(); - return ( - await _getAssociations(dbContext, keys) - .ToListAsync(cancellationToken) - ) - .ToLookup(_getAssociateId); - } -} \ No newline at end of file diff --git a/backend/src/GraphQl/Entities/EntityFilterType.cs b/backend/src/GraphQl/Entities/AuditableEntityFilterType.cs similarity index 90% rename from backend/src/GraphQl/Entities/EntityFilterType.cs rename to backend/src/GraphQl/Entities/AuditableEntityFilterType.cs index c3cc4a9e..8fe8dfca 100644 --- a/backend/src/GraphQl/Entities/EntityFilterType.cs +++ b/backend/src/GraphQl/Entities/AuditableEntityFilterType.cs @@ -1,16 +1,11 @@ -using System; -using System.Linq; -using Database.Data; -using HotChocolate.Configuration; using HotChocolate.Data.Filters; -using HotChocolate.Types; -using HotChocolate.Types.Descriptors.Definitions; +using Database.Data; namespace Database.GraphQl.Entities; -public abstract class EntityFilterType +public abstract class AuditableEntityFilterType : FilterInputType - where TEntity : IEntity + where TEntity : IEntity, IAuditable { protected override void Configure( IFilterInputTypeDescriptor descriptor @@ -18,6 +13,8 @@ IFilterInputTypeDescriptor descriptor { descriptor.BindFieldsExplicitly(); descriptor.Field(x => x.Id); + descriptor.Field(x => x.CreatedAt); + descriptor.Field(x => x.UpdatedAt); // TODO Do we want to filter by: descriptor.Field(x => x.Version); } diff --git a/backend/src/GraphQl/Entities/EntitySortType.cs b/backend/src/GraphQl/Entities/AuditableEntitySortType.cs similarity index 58% rename from backend/src/GraphQl/Entities/EntitySortType.cs rename to backend/src/GraphQl/Entities/AuditableEntitySortType.cs index f3bbdb68..88e613bd 100644 --- a/backend/src/GraphQl/Entities/EntitySortType.cs +++ b/backend/src/GraphQl/Entities/AuditableEntitySortType.cs @@ -1,18 +1,20 @@ -using Database.Data; using HotChocolate.Data.Sorting; +using Database.Data; namespace Database.GraphQl.Entities; -public abstract class EntitySortType +public abstract class AuditableEntitySortType : SortInputType - where TEntity : IEntity + where TEntity : IEntity, IAuditable { protected override void Configure( ISortInputTypeDescriptor descriptor ) { + base.Configure(descriptor); descriptor.BindFieldsExplicitly(); descriptor.Field(x => x.Id); - // descriptor.Field(x => x.Version); + descriptor.Field(x => x.CreatedAt); + descriptor.Field(x => x.UpdatedAt); } } \ No newline at end of file diff --git a/backend/src/GraphQl/Entities/EntityByIdDataLoader.cs b/backend/src/GraphQl/Entities/EntityByIdDataLoader.cs deleted file mode 100644 index a79193fa..00000000 --- a/backend/src/GraphQl/Entities/EntityByIdDataLoader.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Database.Data; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.Entities; - -public abstract class EntityByIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory, - Func> getQueryable - ) - : BatchDataLoader(batchScheduler, options) - where TEntity : class, IEntity -{ - private readonly IDbContextFactory _dbContextFactory = dbContextFactory; - private readonly Func> _getQueryable = getQueryable; - - protected override async Task> LoadBatchAsync( - IReadOnlyList keys, - CancellationToken cancellationToken - ) - { - await using var dbContext = - _dbContextFactory.CreateDbContext(); - return await _getQueryable(dbContext).AsNoTrackingWithIdentityResolution() - .Where(entity => keys.Contains(entity.Id)) - .ToDictionaryAsync( - entity => entity.Id, - entity => (TEntity?)entity, - cancellationToken - ); - } -} \ No newline at end of file diff --git a/backend/src/GraphQl/Entities/EntityType.cs b/backend/src/GraphQl/Entities/EntityType.cs index 6e1402c1..6dfd7111 100644 --- a/backend/src/GraphQl/Entities/EntityType.cs +++ b/backend/src/GraphQl/Entities/EntityType.cs @@ -1,19 +1,21 @@ using System; -using Database.Data; using GreenDonut; using HotChocolate.Types; +using Database.Data; +using Database.GraphQl.Scalars; -namespace Database.GraphQl; +namespace Database.GraphQl.Entities; public abstract class EntityType : ObjectType where TEntity : IEntity - where TEntityByIdDataLoader : IDataLoader + where TEntityByIdDataLoader : IDataLoader { protected override void Configure( IObjectTypeDescriptor descriptor ) { + base.Configure(descriptor); descriptor .ImplementsNode() .IdField(t => t.Id) @@ -21,11 +23,11 @@ IObjectTypeDescriptor descriptor context .DataLoader() .LoadAsync(id, context.RequestAborted) - ! // Notice the null-forgiving operator `!`. It's bad that we need to use it here. ); descriptor .Field(GraphQlConstants.UuidFieldName) .Type>() + .Cost(0) .Resolve(context => context.Parent().Id ); diff --git a/backend/src/GraphQl/ErrorLoggingDiagnosticEventListener.cs b/backend/src/GraphQl/ErrorLoggingDiagnosticEventListener.cs index b91859a6..d574dfd3 100644 --- a/backend/src/GraphQl/ErrorLoggingDiagnosticEventListener.cs +++ b/backend/src/GraphQl/ErrorLoggingDiagnosticEventListener.cs @@ -1,15 +1,13 @@ using System; using System.Collections.Generic; -using System.Globalization; -using System.Text; using System.Text.RegularExpressions; using Database.Logging; using HotChocolate; using HotChocolate.Execution; using HotChocolate.Execution.Instrumentation; -using HotChocolate.Execution.Processing; using HotChocolate.Resolvers; using Microsoft.Extensions.Logging; +using HotChocolate.Language; namespace Database.GraphQl; @@ -27,45 +25,47 @@ public static partial void RequestError( [LoggerMessage( Level = LogLevel.Error, - Message = "Resolver error. Field: '{FieldName}'. Operation: '{Operation}'." + Message = "Request error. Document: {Document}" )] - public static partial void ResolverError( + public static partial void RequestError( this ILogger logger, - Exception? exception, - string fieldName, - IOperation operation, + IOperationDocument? document, [TagProvider(typeof(HotChocolateIErrorTagProvider), nameof(HotChocolateIErrorTagProvider.RecordTags))] IError error ); [LoggerMessage( Level = LogLevel.Error, - Message = "Subscription event error. Operation: {Operation}" + Message = "Resolver error. Field: '{FieldName}'. Document: {Document}" )] - public static partial void SubscriptionEventError( + public static partial void ResolverError( this ILogger logger, - Exception exception, - IOperation operation + string fieldName, + DocumentNode document, + [TagProvider(typeof(HotChocolateIErrorTagProvider), nameof(HotChocolateIErrorTagProvider.RecordTags))] IError error, + Exception? exception ); [LoggerMessage( Level = LogLevel.Error, - Message = "Subscription event error. Operation: {Operation}" + Message = "Resolver error. Field: '{FieldName}'. Document: {Document}" )] - public static partial void SubscriptionTransportError( + public static partial void ResolverError( this ILogger logger, - Exception exception, - IOperation operation + Exception? exception, + string fieldName, + IOperationDocument? document, + [TagProvider(typeof(HotChocolateIErrorTagProvider), nameof(HotChocolateIErrorTagProvider.RecordTags))] IError error ); [LoggerMessage( Level = LogLevel.Error, - Message = "Syntax error. Document: {Document}" + Message = "Subscription event error. Id: '{SubscriptionId}'. Operation: {Document}" )] - public static partial void SyntaxError( + public static partial void SubscriptionEventError( this ILogger logger, - Exception? exception, - IOperationDocument? document, - [TagProvider(typeof(HotChocolateIErrorTagProvider), nameof(HotChocolateIErrorTagProvider.RecordTags))] IError error + Exception exception, + ulong subscriptionId, + IOperationDocument? document ); [LoggerMessage( @@ -128,55 +128,57 @@ ILogger logger : ExecutionDiagnosticEventListener { // this diagnostic event is raised when a request is executed ... - public override IDisposable ExecuteRequest(IRequestContext context) + public override IDisposable ExecuteRequest(RequestContext context) { // ... we will return an activity scope that is used to signal when the request is finished. return new RequestScope(logger, context); } public override void RequestError( - IRequestContext context, - Exception exception + RequestContext context, + Exception error ) { - logger.RequestError(exception, context.Request.Document); - base.RequestError(context, exception); + logger.RequestError(error, context.Request.Document); + base.RequestError(context, error); } - public override void ResolverError( - IMiddlewareContext context, + public override void RequestError( + RequestContext context, IError error ) { - logger.ResolverError(error.Exception, context.Selection.Field.Name, context.Operation, error); - base.ResolverError(context, error); + logger.RequestError(context.Request.Document, error); + base.RequestError(context, error); } - public override void SubscriptionEventError( - SubscriptionEventContext context, - Exception exception + public override void ResolverError( + IMiddlewareContext context, + IError error ) { - logger.SubscriptionEventError(exception, context.Subscription.Operation); - base.SubscriptionEventError(context, exception); + logger.ResolverError(context.Selection.Field.Name, context.Operation.Document, error, error.Exception); + base.ResolverError(context, error); } - public override void SubscriptionTransportError( - ISubscription subscription, - Exception exception + public override void ResolverError( + RequestContext context, + ISelection selection, + IError error ) { - logger.SubscriptionTransportError(exception, subscription.Operation); - base.SubscriptionTransportError(subscription, exception); + logger.ResolverError(error.Exception, selection.Field.Name, context.Request?.Document, error); + base.ResolverError(context, selection, error); } - public override void SyntaxError( - IRequestContext context, - IError error + public override void SubscriptionEventError( + RequestContext context, + ulong subscriptionId, + Exception exception ) { - logger.SyntaxError(error.Exception, context.Request.Document, error); - base.SyntaxError(context, error); + logger.SubscriptionEventError(exception, subscriptionId, context.Request.Document); + base.SubscriptionEventError(context, subscriptionId, exception); } public override void TaskError( @@ -189,7 +191,7 @@ IError error } public override void ValidationErrors( - IRequestContext context, + RequestContext context, IReadOnlyList errors ) { @@ -200,7 +202,7 @@ IReadOnlyList errors base.ValidationErrors(context, errors); } - private sealed partial class RequestScope(ILogger logger, IRequestContext context) : IDisposable + private sealed partial class RequestScope(ILogger logger, RequestContext context) : IDisposable { [GeneratedRegex(@"apiKey|authKey|privateKey|password|passphrase|secret|secure|security|token", RegexOptions.IgnoreCase, "")] private static partial Regex SecretRegex(); @@ -209,43 +211,45 @@ private sealed partial class RequestScope(ILogger" - : variableValue.Value.ToString() - ); - stringBuilder.Append('\''); - stringBuilder.AppendFormat(CultureInfo.InvariantCulture, $"{Environment.NewLine}"); - } - catch (Exception exception) - { - // all input type records will land here. - stringBuilder.AppendFormat(CultureInfo.InvariantCulture, $"Failed stringifying the value: {exception.Message}"); - stringBuilder.AppendFormat(CultureInfo.InvariantCulture, $"{Environment.NewLine}"); - } - } - } - _variables = stringBuilder.ToString(); + // TODO Where are the variables now if not anymore in context.Variables? + // if (_variables is not null) + // { + // return _variables; + // } + // if (context.Variables is null) + // { + // return null; + // } + // StringBuilder stringBuilder = new(); + // foreach (var variableValueCollection in context.Variables) + // { + // foreach (var variableValue in variableValueCollection) + // { + // try + // { + // stringBuilder.AppendFormat( + // CultureInfo.InvariantCulture, + // $"{variableValue.Name} : {variableValue.Type} = " + // ); + // stringBuilder.Append('\''); + // stringBuilder.Append( + // SecretRegex().IsMatch(variableValue.Name) + // ? "" + // : variableValue.Value.ToString() + // ); + // stringBuilder.Append('\''); + // stringBuilder.AppendFormat(CultureInfo.InvariantCulture, $"{Environment.NewLine}"); + // } + // catch (Exception exception) + // { + // // all input type records will land here. + // stringBuilder.AppendFormat(CultureInfo.InvariantCulture, $"Failed stringifying the value: {exception.Message}"); + // stringBuilder.AppendFormat(CultureInfo.InvariantCulture, $"{Environment.NewLine}"); + // } + // } + // } + // _variables = stringBuilder.ToString(); + _variables = null; return _variables; } @@ -253,28 +257,29 @@ public void Dispose() { if (logger.IsEnabled(LogLevel.Debug)) { - if (context.Document is not null) + if (context.OperationDocumentInfo.Document is not null) { #pragma warning disable CA1873 // Evaluation of this argument may be expensive and unnecessary if logging is disabled (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1873) logger.Executed( - context.Document.ToString(true), + context.OperationDocumentInfo.Document.ToString(true), StringifyVariables() ); #pragma warning restore CA1873 } } // when the request is finished it will dispose the activity scope - if (context.Result is IOperationResult { Errors.Count: > 0 } operationResult) + if (context.Result is OperationResult { Errors.Count: > 0 } operationResult) { foreach (var error in operationResult.Errors) { logger.OperationError(context.Request.Document, StringifyVariables(), error); } } - if (context.Exception is { }) - { - logger.UnexpectedExecutionException(context.Request.Document, StringifyVariables(), context.Exception); - } + // TODO Where is the exception now? + // if (context.Exception is { }) + // { + // logger.UnexpectedExecutionException(context.Request.Document, StringifyVariables(), context.Exception); + // } } } } \ No newline at end of file diff --git a/backend/src/GraphQl/Extensions/PageExtensions.cs b/backend/src/GraphQl/Extensions/PageExtensions.cs new file mode 100644 index 00000000..cdbe08dc --- /dev/null +++ b/backend/src/GraphQl/Extensions/PageExtensions.cs @@ -0,0 +1,66 @@ +using System.Threading.Tasks; +using GreenDonut.Data; +using HotChocolate.Types.Pagination; + +namespace Database.GraphQl.Extensions; + +// Inspired by https://github.com/ChilliCream/graphql-platform/blob/main/src/HotChocolate/Data/src/Data/Extensions/HotChocolatePaginationResultExtensions.cs +public static class PageExtensions +{ + public static async ValueTask GetTotalCountAsync( + this Task?> pagePromise + ) + { + return (await pagePromise)?.TotalCount ?? 0; + } + + public static async ValueTask GetPageInfoAsync( + this Task?> pagePromise + ) + { + var page = await pagePromise; + return new ConnectionPageInfo( + page?.HasNextPage ?? false, + page?.HasPreviousPage ?? false, + page?.CreateStartCursor(), + page?.CreateEndCursor() + ); + } + + // public static async Task> ToConnectionAsync( + // this Task> pagePromise, + // Func, PageEntry, Task>> createEdgeAsync, + // Func>, ConnectionPageInfo, int, Connection> createConnection + // ) + // where TTarget : class + // where TSource : class + // { + // return await CreateConnectionAsync(await pagePromise, createEdgeAsync, createConnection); + // } + + // private static async Task> CreateConnectionAsync( + // Page? page, + // Func, PageEntry, Task>> createEdgeAsync, + // Func>, ConnectionPageInfo, int, Connection> createConnection + // ) + // where TTarget : class + // { + // page ??= Page.Empty; + // var entries = page.Entries; + // IEdge[] edges = entries.IsEmpty + // ? [] + // : await Task.WhenAll( + // entries + // .Select(entry => createEdgeAsync(page, entry)) + // .ToList() + // ); + // return createConnection( + // edges, + // new ConnectionPageInfo( + // page.HasNextPage, + // page.HasPreviousPage, + // page.CreateStartCursor(), + // page.CreateEndCursor()), + // page.TotalCount ?? 0); + // } +} \ No newline at end of file diff --git a/backend/src/GraphQl/Extensions/ResolverContextExtensions.cs b/backend/src/GraphQl/Extensions/ResolverContextExtensions.cs index 9c677115..e2a1a926 100644 --- a/backend/src/GraphQl/Extensions/ResolverContextExtensions.cs +++ b/backend/src/GraphQl/Extensions/ResolverContextExtensions.cs @@ -8,16 +8,28 @@ namespace Database.GraphQl.Extensions; public static class ResolverContextExtensions { // Inspired by https://github.com/ChilliCream/graphql-platform/blob/9ae7220205412203d0a941a6b0cc779e70b02b09/src/HotChocolate/Data/src/Data/QueryContextParameterExpressionBuilder.cs#L76-L86 + // Using `QueryContext queryContext,` in resolvers starts up the projection engine producing many problems public static QueryContext GetQueryContext(this IResolverContext context) { var selection = context.Selection; var filterContext = context.GetFilterContext(); var sortContext = context.GetSortingContext(); - // TODO Make selection work return new QueryContext( null, // selection.AsSelector(), filterContext?.AsPredicate(), sortContext?.AsSortDefinition()); } + + // Using `PagingArguments pagingArguments,` in resolvers results in the parameter `pagingArguments: PagingArgumentsInput` in the GraphQL schema + public static PagingArguments GetPagingArguments(this IResolverContext context) + { + return new( + context.ArgumentValue("first"), + context.ArgumentValue("after"), + context.ArgumentValue("last"), + context.ArgumentValue("before"), + includeTotalCount: true + ); + } } \ No newline at end of file diff --git a/backend/src/GraphQl/Extensions/SortingContextExtensions.cs b/backend/src/GraphQl/Extensions/SortingContextExtensions.cs deleted file mode 100644 index 65b43071..00000000 --- a/backend/src/GraphQl/Extensions/SortingContextExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Linq; -using Database.Data; -using GreenDonut.Data; -using HotChocolate.Data.Sorting; - -namespace Database.GraphQl.Extensions; - -public static class SortingContextExtensions -{ - public static void StabilizeOrder(this ISortingContext sorting) where T : IEntity - { - // this signals that the expression was not handled within the resolver - // and the sorting middleware should take over. - sorting.Handled(false); - sorting.OnAfterSortingApplied>( - static (sortingApplied, query) => - { - if (sortingApplied && query is IOrderedQueryable ordered) - { - return ordered.ThenBy(_ => _.Id); - } - return query.OrderBy(_ => _.Id); - }); - } - - public static SortDefinition StabilizeOrder(this SortDefinition sort) where T : IEntity - { - return sort.AddAscending(_ => _.Id); - } -} \ No newline at end of file diff --git a/backend/src/GraphQl/Filters/INotField.cs b/backend/src/GraphQl/Filters/INotField.cs index 3e2b98ac..9b24e3f3 100644 --- a/backend/src/GraphQl/Filters/INotField.cs +++ b/backend/src/GraphQl/Filters/INotField.cs @@ -1,11 +1,10 @@ -using HotChocolate.Data.Filters; -using HotChocolate.Types; - -namespace Database.GraphQl.Filters; - -public interface INotField - : IInputField - , IHasRuntimeType -{ - new IFilterInputType DeclaringType { get; } -} \ No newline at end of file +// using HotChocolate.Data.Filters; +// +// namespace Database.GraphQl.Filters; +// +// public interface INotField +// : IInputField +// , IHasRuntimeType +// { +// new IFilterInputType DeclaringType { get; } +// } \ No newline at end of file diff --git a/backend/src/GraphQl/Filters/NotField.cs b/backend/src/GraphQl/Filters/NotField.cs index 05d14b3d..d7234d79 100644 --- a/backend/src/GraphQl/Filters/NotField.cs +++ b/backend/src/GraphQl/Filters/NotField.cs @@ -1,44 +1,43 @@ -using HotChocolate.Configuration; -using HotChocolate.Data.Filters; -using HotChocolate.Types; -using HotChocolate.Types.Descriptors; -using HotChocolate.Types.Descriptors.Definitions; - -namespace Database.GraphQl.Filters; - -public sealed class NotField - // : FilterOperationField - : InputField - , INotField -{ - internal NotField(IDescriptorContext context, int index, string? scope) - : base(CreateDefinition(context, scope), index) - { - } - - public new FilterInputType DeclaringType => (FilterInputType)base.DeclaringType; - - IFilterInputType INotField.DeclaringType => DeclaringType; - - protected override void OnCompleteField( - ITypeCompletionContext context, - ITypeSystemMember declaringMember, - InputFieldDefinition definition) - { - definition.Type = TypeReference.Parse( - $"[{context.Type.Name}!]", - TypeContext.Input, - context.Type.Scope); - - base.OnCompleteField(context, declaringMember, definition); - } - - private static FilterOperationFieldDefinition CreateDefinition( - IDescriptorContext context, - string? scope) - { - return FilterOperationFieldDescriptor - .New(context, AdditionalFilterOperations.Not, scope) - .CreateDefinition(); - } -} \ No newline at end of file +// using HotChocolate.Configuration; +// using HotChocolate.Data.Filters; +// using HotChocolate.Types; +// using HotChocolate.Types.Descriptors; +// +// namespace Database.GraphQl.Filters; +// +// public sealed class NotField +// // : FilterOperationField +// : InputField +// , INotField +// { +// internal NotField(IDescriptorContext context, int index, string? scope) +// : base(CreateDefinition(context, scope), index) +// { +// } +// +// public new FilterInputType DeclaringType => (FilterInputType)base.DeclaringType; +// +// IFilterInputType INotField.DeclaringType => DeclaringType; +// +// protected override void OnCompleteField( +// ITypeCompletionContext context, +// ITypeSystemMember declaringMember, +// InputFieldDefinition definition) +// { +// definition.Type = TypeReference.Parse( +// $"[{context.Type.Name}!]", +// TypeContext.Input, +// context.Type.Scope); +// +// base.OnCompleteField(context, declaringMember, definition); +// } +// +// private static FilterOperationFieldDefinition CreateDefinition( +// IDescriptorContext context, +// string? scope) +// { +// return FilterOperationFieldDescriptor +// .New(context, AdditionalFilterOperations.Not, scope) +// .CreateDefinition(); +// } +// } \ No newline at end of file diff --git a/backend/src/GraphQl/Filters/ScalarFilterInputTypes.cs b/backend/src/GraphQl/Filters/ScalarFilterInputTypes.cs index a43f2f96..c14e0b46 100644 --- a/backend/src/GraphQl/Filters/ScalarFilterInputTypes.cs +++ b/backend/src/GraphQl/Filters/ScalarFilterInputTypes.cs @@ -1,8 +1,16 @@ using HotChocolate.Data.Filters; using HotChocolate.Types; +using Database.GraphQl.Scalars; +using DateTimeType = HotChocolate.Types.NodaTime.DateTimeType; +using DurationType = HotChocolate.Types.NodaTime.DurationType; +using LocalDateTimeType = HotChocolate.Types.NodaTime.LocalDateTimeType; +using LocalDateType = HotChocolate.Types.NodaTime.LocalDateType; +using LocalTimeType = HotChocolate.Types.NodaTime.LocalTimeType; namespace Database.GraphQl.Filters; +// https://github.com/ChilliCream/graphql-platform/blob/main/src/HotChocolate/Data/src/Data/Filters/Types/ComparableOperationFilterInputType.cs + public abstract class ExtendedComparableOperationFilterInputType : ComparableOperationFilterInputType where T : notnull @@ -76,15 +84,25 @@ protected override void Configure(IFilterInputTypeDescriptor descriptor) } } -// public sealed class LocalDateFilterInputType -// : ExtendedComparableOperationFilterInputType -// { -// protected override void Configure(IFilterInputTypeDescriptor descriptor) -// { -// descriptor.Name($"LocalDate{GraphQlConstants.FilterInputSuffix}"); -// base.Configure(descriptor); -// } -// } +public sealed class LocalDateFilterInputType + : ExtendedComparableOperationFilterInputType +{ + protected override void Configure(IFilterInputTypeDescriptor descriptor) + { + descriptor.Name($"LocalDate{GraphQlConstants.FilterInputSuffix}"); + base.Configure(descriptor); + } +} + +public sealed class LocalDateTimeFilterInputType + : ExtendedComparableOperationFilterInputType +{ + protected override void Configure(IFilterInputTypeDescriptor descriptor) + { + descriptor.Name($"LocalDateTime{GraphQlConstants.FilterInputSuffix}"); + base.Configure(descriptor); + } +} public sealed class LongFilterInputType : ExtendedComparableOperationFilterInputType @@ -96,15 +114,15 @@ protected override void Configure(IFilterInputTypeDescriptor descriptor) } } -// public sealed class LocalTimeFilterInputType -// : ExtendedComparableOperationFilterInputType -// { -// protected override void Configure(IFilterInputTypeDescriptor descriptor) -// { -// descriptor.Name($"LocalTime{GraphQlConstants.FilterInputSuffix}"); -// base.Configure(descriptor); -// } -// } +public sealed class LocalTimeFilterInputType + : ExtendedComparableOperationFilterInputType +{ + protected override void Configure(IFilterInputTypeDescriptor descriptor) + { + descriptor.Name($"LocalTime{GraphQlConstants.FilterInputSuffix}"); + base.Configure(descriptor); + } +} public sealed class FloatFilterInputType : ExtendedComparableOperationFilterInputType @@ -116,12 +134,12 @@ protected override void Configure(IFilterInputTypeDescriptor descriptor) } } -public sealed class TimeSpanFilterInputType - : ExtendedComparableOperationFilterInputType +public sealed class DurationFilterInputType + : ExtendedComparableOperationFilterInputType { protected override void Configure(IFilterInputTypeDescriptor descriptor) { - descriptor.Name($"TimeSpan{GraphQlConstants.FilterInputSuffix}"); + descriptor.Name($"Duration{GraphQlConstants.FilterInputSuffix}"); base.Configure(descriptor); } } @@ -146,8 +164,8 @@ protected override void Configure(IFilterInputTypeDescriptor descriptor) } } -public sealed class UrlFilterInputType - : ExtendedComparableOperationFilterInputType +public sealed class UriFilterInputType + : ExtendedComparableOperationFilterInputType { protected override void Configure(IFilterInputTypeDescriptor descriptor) { diff --git a/backend/src/GraphQl/GeometricDataX/CreateGeometricDataMutation.cs b/backend/src/GraphQl/GeometricDataX/CreateGeometricDataMutation.cs index b11f44cc..52739ea5 100644 --- a/backend/src/GraphQl/GeometricDataX/CreateGeometricDataMutation.cs +++ b/backend/src/GraphQl/GeometricDataX/CreateGeometricDataMutation.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Database.ApiRequests; @@ -9,8 +8,8 @@ using Database.Data; using Database.Extensions; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; -using Database.Utilities; using HotChocolate; using HotChocolate.Types; using NodaTime; @@ -23,7 +22,7 @@ public sealed record CreateGeometricDataInput( string? Name, string? Description, string[] Warnings, - OffsetDateTime CreatedAt, + DateTimeOffset CreatedAt, Guid CreatorId, AppliedMethodInput AppliedMethod, RootGetHttpsResourceInput RootResource, @@ -111,6 +110,7 @@ public async Task CreateGeometricDataAsync( IDataByDatabaseAndIdAndKindDataLoader dataByDatabaseAndIdAndKindDataLoader, IDataFormatByIdDataLoader dataFormatByIdDataLoader, ResponseApprovalService responseApprovalService, + IClock clock, CancellationToken cancellationToken ) { @@ -140,6 +140,7 @@ CancellationToken cancellationToken CreateGeometricDataErrorCode.UNKNOWN_DATA, dataFormatByIdDataLoader, CreateGeometricDataErrorCode.UNKNOWN_DATA_FORMAT, + clock, cancellationToken ) ).Failed(out var dataFormat, out var validateErrorPayload) @@ -172,4 +173,4 @@ CancellationToken cancellationToken return NewPayload(geometricData, null); } -} +} \ No newline at end of file diff --git a/backend/src/GraphQl/GeometricDataX/GeometricDataByIdDataLoader.cs b/backend/src/GraphQl/GeometricDataX/GeometricDataByIdDataLoader.cs deleted file mode 100644 index ef2c94e3..00000000 --- a/backend/src/GraphQl/GeometricDataX/GeometricDataByIdDataLoader.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.GeometricDataX; - -public sealed class GeometricDataByIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : EntityByIdDataLoader( - batchScheduler, - options, - dbContextFactory, - dbContext => dbContext.GeometricData - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/GeometricDataX/GeometricDataLoaders.cs b/backend/src/GraphQl/GeometricDataX/GeometricDataLoaders.cs new file mode 100644 index 00000000..703cf473 --- /dev/null +++ b/backend/src/GraphQl/GeometricDataX/GeometricDataLoaders.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace Database.GraphQl.GeometricDataX; + +public sealed class GeometricDataLoaders +: DataLoaders +{ + [DataLoader] + public static ValueTask> GetGeometricDataByIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetEntityByIdAsync( + ids, + databaseContext => databaseContext.GeometricData, + queryContext, + databaseContextFactory, + cancellationToken + ); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/GeometricDataX/GeometricDataQueries.cs b/backend/src/GraphQl/GeometricDataX/GeometricDataQueries.cs index 2520920a..ba3fcfbe 100644 --- a/backend/src/GraphQl/GeometricDataX/GeometricDataQueries.cs +++ b/backend/src/GraphQl/GeometricDataX/GeometricDataQueries.cs @@ -1,14 +1,13 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Database.Authorization; using Database.Data; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; using HotChocolate; using HotChocolate.Data; -using HotChocolate.Data.Sorting; using HotChocolate.Resolvers; using HotChocolate.Types; @@ -21,11 +20,10 @@ public sealed class GeometricDataQueries [UsePaging] [UseFiltering] [UseSorting] - public Task> GetAllGeometricDataAsync( + public Task> GetAllGeometricDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, - ISortingContext sorting, IResolverContext resolverContext, CancellationToken cancellationToken ) @@ -34,7 +32,6 @@ CancellationToken cancellationToken context.GeometricData, locale, accessRightsService, - sorting, resolverContext, cancellationToken ); @@ -43,11 +40,10 @@ CancellationToken cancellationToken [UsePaging] [UseFiltering] [UseSorting] - public Task> GetAllPendingGeometricDataAsync( + public Task> GetAllPendingGeometricDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, - ISortingContext sorting, IResolverContext resolverContext, CommonAuthorization authorization, CancellationToken cancellationToken @@ -57,7 +53,6 @@ CancellationToken cancellationToken context.GeometricData, locale, accessRightsService, - sorting, resolverContext, authorization, cancellationToken diff --git a/backend/src/GraphQl/GeometricDataX/GeometricDataType.cs b/backend/src/GraphQl/GeometricDataX/GeometricDataType.cs index 978f7481..c1caea06 100644 --- a/backend/src/GraphQl/GeometricDataX/GeometricDataType.cs +++ b/backend/src/GraphQl/GeometricDataX/GeometricDataType.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.GeometricDataX; public sealed class GeometricDataType - : DataTypeBase + : DataTypeBase { protected override void Configure( IObjectTypeDescriptor descriptor diff --git a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceByIdDataLoader.cs b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceByIdDataLoader.cs deleted file mode 100644 index f937d1f3..00000000 --- a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceByIdDataLoader.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.GetHttpsResources; - -public sealed class GetHttpsResourceByIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : EntityByIdDataLoader( - batchScheduler, - options, - dbContextFactory, - dbContext => dbContext.GetHttpsResources - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceChildrenByGetHttpsResourceIdDataLoader.cs b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceChildrenByGetHttpsResourceIdDataLoader.cs deleted file mode 100644 index 276b992d..00000000 --- a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceChildrenByGetHttpsResourceIdDataLoader.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Linq; -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.GetHttpsResources; - -public sealed class GetHttpsResourceChildrenByGetHttpsResourceIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : AssociationsByAssociateIdDataLoader( - batchScheduler, - options, - dbContextFactory, - (dbContext, ids) => - dbContext.GetHttpsResources.AsNoTracking().Where(x => - ids.Contains(x.ParentId ?? Guid.Empty) - ), - x => x.Id - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceDataLoaders.cs b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceDataLoaders.cs new file mode 100644 index 00000000..a04f9b0a --- /dev/null +++ b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceDataLoaders.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace Database.GraphQl.GetHttpsResources; + +public sealed class GetHttpsResourceDataLoaders +: DataLoaders +{ + [DataLoader] + public static ValueTask> GetGetHttpsResourceByIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetEntityByIdAsync( + ids, + databaseContext => databaseContext.GetHttpsResources, + queryContext, + databaseContextFactory, + cancellationToken + ); + } + + [DataLoader] + public static ValueTask> GetHttpsResourceChildrenByGetHttpsResourceIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetManyByOneIdAsync( + ids, + (databaseContext) => databaseContext.GetHttpsResources, + _ => _.ParentId ?? Guid.Empty, + queryContext, + databaseContextFactory, + cancellationToken + ); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceFilterType.cs b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceFilterType.cs index 6e1a5f92..312f34d0 100644 --- a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceFilterType.cs +++ b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceFilterType.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.GetHttpsResources; public class GetHttpsResourceFilterType - : EntityFilterType + : AuditableEntityFilterType { protected override void Configure( IFilterInputTypeDescriptor descriptor diff --git a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceQueries.cs b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceQueries.cs index 95850511..8e2df359 100644 --- a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceQueries.cs +++ b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceQueries.cs @@ -6,8 +6,9 @@ using Database.Data; using Database.Enumerations; using Database.GraphQl.Extensions; +using GreenDonut.Data; using HotChocolate.Data; -using HotChocolate.Data.Sorting; +using HotChocolate.Resolvers; using HotChocolate.Types; using Microsoft.EntityFrameworkCore; @@ -17,58 +18,61 @@ namespace Database.GraphQl.GetHttpsResources; public sealed class GetHttpsResourceQueries { [UsePaging] - // [UseProjection] // We disabled projections because when requesting `id` all results had the same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public IQueryable GetGetHttpsResources( + public ValueTask> GetGetHttpsResources( ApplicationDbContext context, - ISortingContext sorting + IResolverContext resolverContext, + CancellationToken cancellationToken ) { - sorting.StabilizeOrder(); return context.GetHttpsResources.AsNoTracking() .Where(_ => _.CalorimetricData == null || _.CalorimetricData.PublishingState != PublishingState.PENDING) .Where(_ => _.GeometricData == null || _.GeometricData.PublishingState != PublishingState.PENDING) .Where(_ => _.HygrothermalData == null || _.HygrothermalData.PublishingState != PublishingState.PENDING) .Where(_ => _.LifeCycleData == null || _.LifeCycleData.PublishingState != PublishingState.PENDING) .Where(_ => _.OpticalData == null || _.OpticalData.PublishingState != PublishingState.PENDING) - .Where(_ => _.PhotovoltaicData == null || _.PhotovoltaicData.PublishingState != PublishingState.PENDING); + .Where(_ => _.PhotovoltaicData == null || _.PhotovoltaicData.PublishingState != PublishingState.PENDING) + .With(resolverContext.GetQueryContext(), Sorting.DefaultEntityOrder) + .ToPageAsync(resolverContext.GetPagingArguments(), cancellationToken) + .ToConnectionAsync(); } [UsePaging] // [UseProjection] // We disabled projections because when requesting `id` all results had the same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public async Task> GetPendingGetHttpsResources( + public async ValueTask> GetPendingGetHttpsResources( ApplicationDbContext context, - ISortingContext sorting, CommonAuthorization authorization, + IResolverContext resolverContext, CancellationToken cancellationToken ) { if (!await authorization.IsDatabaseOperator(cancellationToken)) { - return Enumerable.Empty().AsQueryable(); + return await Enumerable.Empty().AsQueryable() + .ToPageAsync(resolverContext.GetPagingArguments(), cancellationToken) + .ToConnectionAsync(); } - sorting.StabilizeOrder(); - return context.GetHttpsResources.AsNoTracking() + return await context.GetHttpsResources.AsNoTracking() .Where(_ => _.CalorimetricData == null || _.CalorimetricData.PublishingState == PublishingState.PENDING) .Where(_ => _.GeometricData == null || _.GeometricData.PublishingState == PublishingState.PENDING) .Where(_ => _.HygrothermalData == null || _.HygrothermalData.PublishingState == PublishingState.PENDING) .Where(_ => _.LifeCycleData == null || _.LifeCycleData.PublishingState == PublishingState.PENDING) .Where(_ => _.OpticalData == null || _.OpticalData.PublishingState == PublishingState.PENDING) - .Where(_ => _.PhotovoltaicData == null || _.PhotovoltaicData.PublishingState == PublishingState.PENDING); + .Where(_ => _.PhotovoltaicData == null || _.PhotovoltaicData.PublishingState == PublishingState.PENDING) + .With(resolverContext.GetQueryContext(), Sorting.DefaultEntityOrder) + .ToPageAsync(resolverContext.GetPagingArguments(), cancellationToken) + .ToConnectionAsync(); } public Task GetGetHttpsResourceAsync( Guid id, - GetHttpsResourceByIdDataLoader byId, + IGetHttpsResourceByIdDataLoader byId, CancellationToken cancellationToken ) { - return byId.LoadAsync( - id, - cancellationToken - ); + return byId.LoadAsync(id, cancellationToken); } } \ No newline at end of file diff --git a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceResolvers.cs b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceResolvers.cs index d6801662..aca20aa5 100644 --- a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceResolvers.cs +++ b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceResolvers.cs @@ -16,12 +16,12 @@ public sealed class GetHttpsResourceResolvers { public async Task GetData( [Parent] GetHttpsResource getHttpsResource, - CalorimetricDataByIdDataLoader calorimetricDataById, - HygrothermalDataByIdDataLoader hygrothermalDataById, - LifeCycleDataByIdDataLoader lifeCycleDataById, - OpticalDataByIdDataLoader opticalDataById, - PhotovoltaicDataByIdDataLoader photovoltaicDataById, - GeometricDataByIdDataLoader geometricDataById, + ICalorimetricDataByIdDataLoader calorimetricDataById, + IHygrothermalDataByIdDataLoader hygrothermalDataById, + ILifeCycleDataByIdDataLoader lifeCycleDataById, + IOpticalDataByIdDataLoader opticalDataById, + IPhotovoltaicDataByIdDataLoader photovoltaicDataById, + IGeometricDataByIdDataLoader geometricDataById, CancellationToken cancellationToken ) { @@ -83,7 +83,7 @@ AppSettings appSettings public async Task GetParent( [Parent] GetHttpsResource getHttpsResource, - GetHttpsResourceByIdDataLoader byId, + IGetHttpsResourceByIdDataLoader byId, CancellationToken cancellationToken ) { @@ -94,7 +94,7 @@ CancellationToken cancellationToken public Task GetChildren( [Parent] GetHttpsResource getHttpsResource, - GetHttpsResourceChildrenByGetHttpsResourceIdDataLoader byId, + IHttpsResourceChildrenByGetHttpsResourceIdDataLoader byId, CancellationToken cancellationToken ) { diff --git a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceSortType.cs b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceSortType.cs index c41a6e9f..9504cc60 100644 --- a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceSortType.cs +++ b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceSortType.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.GetHttpsResources; public class GetHttpsResourceSortType - : EntitySortType + : AuditableEntitySortType { protected override void Configure( ISortInputTypeDescriptor descriptor diff --git a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceType.cs b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceType.cs index f93cac99..01bc6cab 100644 --- a/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceType.cs +++ b/backend/src/GraphQl/GetHttpsResources/GetHttpsResourceType.cs @@ -1,10 +1,11 @@ using Database.Data; +using Database.GraphQl.Entities; using HotChocolate.Types; namespace Database.GraphQl.GetHttpsResources; public sealed class GetHttpsResourceType - : EntityType + : EntityType { protected override void Configure( IObjectTypeDescriptor descriptor diff --git a/backend/src/GraphQl/GetHttpsResources/RecomputeGetHttpsResourceHashValuesMutation.cs b/backend/src/GraphQl/GetHttpsResources/RecomputeGetHttpsResourceHashValuesMutation.cs index 00e5503a..6e232dc3 100644 --- a/backend/src/GraphQl/GetHttpsResources/RecomputeGetHttpsResourceHashValuesMutation.cs +++ b/backend/src/GraphQl/GetHttpsResources/RecomputeGetHttpsResourceHashValuesMutation.cs @@ -88,8 +88,6 @@ public async Task RecomputeGetHttpsR CancellationToken cancellationToken ) { - var queryContext = resolverContext.GetQueryContext(); - if ((await AuthorizeAsync( RecomputeGetHttpsResourceHashValuesErrorCode.UNAUTHENTICATED, RecomputeGetHttpsResourceHashValuesErrorCode.UNAUTHORIZED, @@ -101,10 +99,9 @@ CancellationToken cancellationToken { return authorizeErrorPayload; } - var resources = await context.GetHttpsResourcesWithData - .With(queryContext, sort => sort.StabilizeOrder()) + .With(resolverContext.GetQueryContext(), Sorting.DefaultEntityOrder) .ToListAsync(cancellationToken); var errors = new ConcurrentBag(); await Parallel.ForEachAsync( diff --git a/backend/src/GraphQl/GraphQlConstants.cs b/backend/src/GraphQl/GraphQlConstants.cs index 5854e8a3..b8ce2ece 100644 --- a/backend/src/GraphQl/GraphQlConstants.cs +++ b/backend/src/GraphQl/GraphQlConstants.cs @@ -2,11 +2,13 @@ namespace Database.GraphQl; internal static class GraphQlConstants { + internal const uint MaximumPageSize = 100; internal const string EndpointPath = "/graphql"; internal const string CorsPolicy = "GraphQlCorsPolicy"; internal const string TypeDiscriminatorPropertyName = "__typename"; internal const string FilterInputSuffix = "PropositionInput"; internal const string SortInputSuffix = "SortInput"; + internal const string PendingPrefix = "pending"; internal const string UuidFieldName = "uuid"; internal const string VersionFieldName = "version"; } \ No newline at end of file diff --git a/backend/src/GraphQl/GraphQlThrowHelper.cs b/backend/src/GraphQl/GraphQlThrowHelper.cs new file mode 100644 index 00000000..45fdff6d --- /dev/null +++ b/backend/src/GraphQl/GraphQlThrowHelper.cs @@ -0,0 +1,52 @@ +using System; +using System.Text.Json; +using HotChocolate; +using HotChocolate.Language; +using HotChocolate.Types; + +namespace Database.GraphQl; + +// https://github.com/ChilliCream/graphql-platform/blob/main/src/HotChocolate/Core/src/Types/Utilities/ThrowHelper.cs +public static class GraphQlThrowHelper +{ + public static LeafCoercionException ScalarCannotCoerceInputLiteral( + ITypeDefinition scalarType, + IValueNode? valueLiteral, + Exception? error = null) + { + valueLiteral ??= NullValueNode.Default; + var errorBuilder = + ErrorBuilder.New() + .SetMessage( + GraphQlTypeResources.ScalarCannotCoerceInputLiteral, + scalarType.Name, + valueLiteral.Kind); + if (error is not null) + { + errorBuilder.SetException(error); + } + return new LeafCoercionException( + errorBuilder.Build(), + scalarType); + } + + public static LeafCoercionException ScalarCannotCoerceInputValue( + ITypeDefinition scalarType, + JsonElement inputValue, + Exception? error = null) + { + var errorBuilder = + ErrorBuilder.New() + .SetMessage( + GraphQlTypeResources.ScalarCannotCoerceInputValue, + scalarType.Name, + inputValue.ValueKind); + if (error is not null) + { + errorBuilder.SetException(error); + } + return new LeafCoercionException( + errorBuilder.Build(), + scalarType); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/GraphQlTypeResources.cs b/backend/src/GraphQl/GraphQlTypeResources.cs new file mode 100644 index 00000000..d51cf4ca --- /dev/null +++ b/backend/src/GraphQl/GraphQlTypeResources.cs @@ -0,0 +1,8 @@ +namespace Database.GraphQl; + +// Inspired by https://github.com/ChilliCream/graphql-platform/blob/main/src/HotChocolate/Core/src/Types/Properties/TypeResources.resx +public static class GraphQlTypeResources +{ + public const string ScalarCannotCoerceInputLiteral = "{0} cannot coerce the given literal of type `{1}` to a runtime value."; + public const string ScalarCannotCoerceInputValue = "{0} cannot coerce the given value JSON element of type `{1}` to a runtime value."; +} \ No newline at end of file diff --git a/backend/src/GraphQl/HygrothermalDataX/CreateHygrothermalDataMutation.cs b/backend/src/GraphQl/HygrothermalDataX/CreateHygrothermalDataMutation.cs index c182836a..0803a74e 100644 --- a/backend/src/GraphQl/HygrothermalDataX/CreateHygrothermalDataMutation.cs +++ b/backend/src/GraphQl/HygrothermalDataX/CreateHygrothermalDataMutation.cs @@ -8,6 +8,7 @@ using Database.Data; using Database.Extensions; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; using HotChocolate; using HotChocolate.Types; @@ -21,7 +22,7 @@ public sealed record CreateHygrothermalDataInput( string? Name, string? Description, string[] Warnings, - OffsetDateTime CreatedAt, + DateTimeOffset CreatedAt, Guid CreatorId, AppliedMethodInput AppliedMethod, RootGetHttpsResourceInput RootResource @@ -102,6 +103,7 @@ public async Task CreateHygrothermalDataAsync( IDataByDatabaseAndIdAndKindDataLoader dataByDatabaseAndIdAndKindDataLoader, IDataFormatByIdDataLoader dataFormatByIdDataLoader, ResponseApprovalService responseApprovalService, + IClock clock, CancellationToken cancellationToken ) { @@ -131,6 +133,7 @@ CancellationToken cancellationToken CreateHygrothermalDataErrorCode.UNKNOWN_DATA, dataFormatByIdDataLoader, CreateHygrothermalDataErrorCode.UNKNOWN_DATA_FORMAT, + clock, cancellationToken ) ).Failed(out var dataFormat, out var validateErrorPayload) @@ -163,4 +166,4 @@ CancellationToken cancellationToken return NewPayload(hygrothermalData, null); } -} +} \ No newline at end of file diff --git a/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataByIdDataLoader.cs b/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataByIdDataLoader.cs deleted file mode 100644 index deac4fcf..00000000 --- a/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataByIdDataLoader.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.HygrothermalDataX; - -public sealed class HygrothermalDataByIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : EntityByIdDataLoader( - batchScheduler, - options, - dbContextFactory, - dbContext => dbContext.HygrothermalData - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataLoaders.cs b/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataLoaders.cs new file mode 100644 index 00000000..b21bfca6 --- /dev/null +++ b/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataLoaders.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace Database.GraphQl.HygrothermalDataX; + +public sealed class HygrothermalDataLoaders +: DataLoaders +{ + [DataLoader] + public static ValueTask> GetHygrothermalDataByIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetEntityByIdAsync( + ids, + databaseContext => databaseContext.HygrothermalData, + queryContext, + databaseContextFactory, + cancellationToken + ); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataQueries.cs b/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataQueries.cs index 6f22f619..335dc032 100644 --- a/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataQueries.cs +++ b/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataQueries.cs @@ -1,14 +1,13 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Database.Authorization; using Database.Data; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; using HotChocolate; using HotChocolate.Data; -using HotChocolate.Data.Sorting; using HotChocolate.Resolvers; using HotChocolate.Types; @@ -23,12 +22,11 @@ public sealed class HygrothermalDataQueries // same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public Task> GetAllHygrothermalDataAsync( + public Task> GetAllHygrothermalDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, IResolverContext resolverContext, - ISortingContext sorting, CancellationToken cancellationToken ) { @@ -36,7 +34,6 @@ CancellationToken cancellationToken context.HygrothermalData, locale, accessRightsService, - sorting, resolverContext, cancellationToken ); @@ -47,12 +44,11 @@ CancellationToken cancellationToken // same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public Task> GetAllPendingHygrothermalDataAsync( + public Task> GetAllPendingHygrothermalDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, IResolverContext resolverContext, - ISortingContext sorting, CommonAuthorization authorization, CancellationToken cancellationToken ) @@ -61,7 +57,6 @@ CancellationToken cancellationToken context.HygrothermalData, locale, accessRightsService, - sorting, resolverContext, authorization, cancellationToken diff --git a/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataType.cs b/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataType.cs index 78a411af..9f5ec67d 100644 --- a/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataType.cs +++ b/backend/src/GraphQl/HygrothermalDataX/HygrothermalDataType.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.HygrothermalDataX; public sealed class HygrothermalDataType - : DataTypeBase + : DataTypeBase { protected override void Configure( IObjectTypeDescriptor descriptor diff --git a/backend/src/GraphQl/LifeCycleDataX/CreateLifeCycleDataMutation.cs b/backend/src/GraphQl/LifeCycleDataX/CreateLifeCycleDataMutation.cs index 0880b08d..a276eba6 100644 --- a/backend/src/GraphQl/LifeCycleDataX/CreateLifeCycleDataMutation.cs +++ b/backend/src/GraphQl/LifeCycleDataX/CreateLifeCycleDataMutation.cs @@ -8,6 +8,7 @@ using Database.Data; using Database.Extensions; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; using HotChocolate; using HotChocolate.Types; @@ -21,7 +22,7 @@ public sealed record CreateLifeCycleDataInput( string? Name, string? Description, string[] Warnings, - OffsetDateTime CreatedAt, + DateTimeOffset CreatedAt, Guid CreatorId, AppliedMethodInput AppliedMethod, RootGetHttpsResourceInput RootResource @@ -102,6 +103,7 @@ public async Task CreateLifeCycleDataAsync( IDataByDatabaseAndIdAndKindDataLoader dataByDatabaseAndIdAndKindDataLoader, IDataFormatByIdDataLoader dataFormatByIdDataLoader, ResponseApprovalService responseApprovalService, + IClock clock, CancellationToken cancellationToken ) { @@ -131,6 +133,7 @@ CancellationToken cancellationToken CreateLifeCycleDataErrorCode.UNKNOWN_DATA, dataFormatByIdDataLoader, CreateLifeCycleDataErrorCode.UNKNOWN_DATA_FORMAT, + clock, cancellationToken ) ).Failed(out var dataFormat, out var validateErrorPayload) @@ -163,4 +166,4 @@ CancellationToken cancellationToken return NewPayload(lifeCycleData, null); } -} +} \ No newline at end of file diff --git a/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataByIdDataLoader.cs b/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataByIdDataLoader.cs deleted file mode 100644 index c3bd0833..00000000 --- a/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataByIdDataLoader.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.LifeCycleDataX; - -public sealed class LifeCycleDataByIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : EntityByIdDataLoader( - batchScheduler, - options, - dbContextFactory, - dbContext => dbContext.LifeCycleData - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataLoaders.cs b/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataLoaders.cs new file mode 100644 index 00000000..a0c8dbb7 --- /dev/null +++ b/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataLoaders.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace Database.GraphQl.LifeCycleDataX; + +public sealed class LifeCycleDataLoaders +: DataLoaders +{ + [DataLoader] + public static ValueTask> GetLifeCycleDataByIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetEntityByIdAsync( + ids, + databaseContext => databaseContext.LifeCycleData, + queryContext, + databaseContextFactory, + cancellationToken + ); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataQueries.cs b/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataQueries.cs index 01ffe18e..1f088c6d 100644 --- a/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataQueries.cs +++ b/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataQueries.cs @@ -1,14 +1,13 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Database.Authorization; using Database.Data; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; using HotChocolate; using HotChocolate.Data; -using HotChocolate.Data.Sorting; using HotChocolate.Resolvers; using HotChocolate.Types; @@ -23,12 +22,11 @@ public sealed class LifeCycleDataQueries // same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public Task> GetAllLifeCycleDataAsync( + public Task> GetAllLifeCycleDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, IResolverContext resolverContext, - ISortingContext sorting, CancellationToken cancellationToken ) { @@ -36,7 +34,6 @@ CancellationToken cancellationToken context.LifeCycleData, locale, accessRightsService, - sorting, resolverContext, cancellationToken ); @@ -47,12 +44,11 @@ CancellationToken cancellationToken // same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public Task> GetAllPendingLifeCycleDataAsync( + public Task> GetAllPendingLifeCycleDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, IResolverContext resolverContext, - ISortingContext sorting, CommonAuthorization authorization, CancellationToken cancellationToken ) @@ -61,7 +57,6 @@ CancellationToken cancellationToken context.LifeCycleData, locale, accessRightsService, - sorting, resolverContext, authorization, cancellationToken diff --git a/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataType.cs b/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataType.cs index 3498cec9..0aebaf0e 100644 --- a/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataType.cs +++ b/backend/src/GraphQl/LifeCycleDataX/LifeCycleDataType.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.LifeCycleDataX; public sealed class LifeCycleDataType - : DataTypeBase + : DataTypeBase { protected override void Configure( IObjectTypeDescriptor descriptor diff --git a/backend/src/GraphQl/OpticalDataX/CreateOpticalDataMutation.cs b/backend/src/GraphQl/OpticalDataX/CreateOpticalDataMutation.cs index 8f71758c..7802a3c9 100644 --- a/backend/src/GraphQl/OpticalDataX/CreateOpticalDataMutation.cs +++ b/backend/src/GraphQl/OpticalDataX/CreateOpticalDataMutation.cs @@ -10,6 +10,7 @@ using Database.Enumerations; using Database.Extensions; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; using HotChocolate; using HotChocolate.Types; @@ -23,7 +24,7 @@ public sealed record CreateOpticalDataInput( string? Name, string? Description, string[] Warnings, - OffsetDateTime CreatedAt, + DateTimeOffset CreatedAt, Guid CreatorId, OpticalComponentType? Type, OpticalComponentSubtype? Subtype, @@ -124,6 +125,7 @@ public async Task CreateOpticalDataAsync( IDataByDatabaseAndIdAndKindDataLoader dataByDatabaseAndIdAndKindDataLoader, IDataFormatByIdDataLoader dataFormatByIdDataLoader, ResponseApprovalService responseApprovalService, + IClock clock, CancellationToken cancellationToken ) { @@ -153,6 +155,7 @@ CancellationToken cancellationToken CreateOpticalDataErrorCode.UNKNOWN_DATA, dataFormatByIdDataLoader, CreateOpticalDataErrorCode.UNKNOWN_DATA_FORMAT, + clock, cancellationToken ) ).Failed(out var dataFormat, out var validateErrorPayload) @@ -185,4 +188,4 @@ CancellationToken cancellationToken return NewPayload(opticalData, null); } -} +} \ No newline at end of file diff --git a/backend/src/GraphQl/OpticalDataX/OpticalDataByIdDataLoader.cs b/backend/src/GraphQl/OpticalDataX/OpticalDataByIdDataLoader.cs deleted file mode 100644 index e1a27bc1..00000000 --- a/backend/src/GraphQl/OpticalDataX/OpticalDataByIdDataLoader.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.OpticalDataX; - -public sealed class OpticalDataByIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : EntityByIdDataLoader( - batchScheduler, - options, - dbContextFactory, - dbContext => dbContext.OpticalData - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/OpticalDataX/OpticalDataLoaders.cs b/backend/src/GraphQl/OpticalDataX/OpticalDataLoaders.cs new file mode 100644 index 00000000..08ec8081 --- /dev/null +++ b/backend/src/GraphQl/OpticalDataX/OpticalDataLoaders.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace Database.GraphQl.OpticalDataX; + +public sealed class OpticalDataLoaders +: DataLoaders +{ + [DataLoader] + public static ValueTask> GetOpticalDataByIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetEntityByIdAsync( + ids, + databaseContext => databaseContext.OpticalData, + queryContext, + databaseContextFactory, + cancellationToken + ); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/OpticalDataX/OpticalDataQueries.cs b/backend/src/GraphQl/OpticalDataX/OpticalDataQueries.cs index 24196d2f..b9bd9864 100644 --- a/backend/src/GraphQl/OpticalDataX/OpticalDataQueries.cs +++ b/backend/src/GraphQl/OpticalDataX/OpticalDataQueries.cs @@ -1,14 +1,13 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Database.Authorization; using Database.Data; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; using HotChocolate; using HotChocolate.Data; -using HotChocolate.Data.Sorting; using HotChocolate.Resolvers; using HotChocolate.Types; @@ -23,11 +22,10 @@ public sealed class OpticalDataQueries // same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public Task> GetAllOpticalDataAsync( + public Task> GetAllOpticalDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, - ISortingContext sorting, IResolverContext resolverContext, CancellationToken cancellationToken ) @@ -36,7 +34,6 @@ CancellationToken cancellationToken context.OpticalData, locale, accessRightsService, - sorting, resolverContext, cancellationToken ); @@ -47,11 +44,10 @@ CancellationToken cancellationToken // same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public Task> GetAllPendingOpticalDataAsync( + public Task> GetAllPendingOpticalDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, - ISortingContext sorting, IResolverContext resolverContext, CommonAuthorization authorization, CancellationToken cancellationToken @@ -61,7 +57,6 @@ CancellationToken cancellationToken context.OpticalData, locale, accessRightsService, - sorting, resolverContext, authorization, cancellationToken diff --git a/backend/src/GraphQl/OpticalDataX/OpticalDataType.cs b/backend/src/GraphQl/OpticalDataX/OpticalDataType.cs index b1e97cf3..5adb469b 100644 --- a/backend/src/GraphQl/OpticalDataX/OpticalDataType.cs +++ b/backend/src/GraphQl/OpticalDataX/OpticalDataType.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.OpticalDataX; public sealed class OpticalDataType - : DataTypeBase + : DataTypeBase { protected override void Configure( IObjectTypeDescriptor descriptor diff --git a/backend/src/GraphQl/PaginatedConnection.cs b/backend/src/GraphQl/PaginatedConnection.cs new file mode 100644 index 00000000..cfb462d4 --- /dev/null +++ b/backend/src/GraphQl/PaginatedConnection.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using HotChocolate.CostAnalysis.Types; +using HotChocolate.Types.Pagination; +using Database.Data; +using Database.GraphQl.Extensions; + +namespace Database.GraphQl; + +public abstract class PaginatedConnection< + TSubject, + TAssociation, + TEdge, + TAssociationsByOneIdDataLoader +>( + TSubject subject, + Func createEdge, + PagingArguments pagingArguments, + QueryContext queryContext +) + where TSubject : IEntity + where TAssociation : class + where TAssociationsByOneIdDataLoader : IDataLoader> +{ + protected TSubject Subject { get; } = subject; + + [Cost(0)] + public ValueTask GetTotalCountAsync( + TAssociationsByOneIdDataLoader dataLoader, + CancellationToken cancellationToken + ) + { + return dataLoader + .With(pagingArguments, queryContext) + .LoadAsync(Subject.Id, cancellationToken) + .GetTotalCountAsync(); + } + + [Cost(0)] + public ValueTask GetPageInfoAsync( + TAssociationsByOneIdDataLoader dataLoader, + CancellationToken cancellationToken + ) + { + return dataLoader + .With(pagingArguments, queryContext) + .LoadAsync(Subject.Id, cancellationToken) + .GetPageInfoAsync(); + } + + [Cost(0)] + public async IAsyncEnumerable GetEdgesAsync( + TAssociationsByOneIdDataLoader dataLoader, + [EnumeratorCancellation] CancellationToken cancellationToken + ) + { + var page = + await dataLoader + .With(pagingArguments, queryContext) + .LoadAsync(Subject.Id, cancellationToken); + if (page is null) + { + yield break; + } + foreach (var entry in page.Entries) + { + yield return createEdge(entry.Item, page.CreateCursor(entry)); + } + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/PaginatedEdge.cs b/backend/src/GraphQl/PaginatedEdge.cs new file mode 100644 index 00000000..07678c18 --- /dev/null +++ b/backend/src/GraphQl/PaginatedEdge.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using HotChocolate.CostAnalysis.Types; + +namespace Database.GraphQl; + +public abstract record PaginatedEdge( + TNode Node, + string Cursor +); + +public abstract class PaginatedEdge( + Guid nodeId, + string cursor +) + where TNodeByIdDataLoader : IDataLoader + where TNode : notnull +{ + [Cost(0)] + public Task GetNodeAsync( + TNodeByIdDataLoader byId, + CancellationToken cancellationToken + ) + { + return byId.LoadRequiredAsync(nodeId, cancellationToken); + } + + public string Cursor => cursor; +} \ No newline at end of file diff --git a/backend/src/GraphQl/PhotovoltaicDataX/CreatePhotovoltaicDataMutation.cs b/backend/src/GraphQl/PhotovoltaicDataX/CreatePhotovoltaicDataMutation.cs index 42014c7b..2729dd9a 100644 --- a/backend/src/GraphQl/PhotovoltaicDataX/CreatePhotovoltaicDataMutation.cs +++ b/backend/src/GraphQl/PhotovoltaicDataX/CreatePhotovoltaicDataMutation.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Database.ApiRequests; @@ -9,8 +8,8 @@ using Database.Data; using Database.Extensions; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; -using Database.Utilities; using HotChocolate; using HotChocolate.Types; using NodaTime; @@ -23,7 +22,7 @@ public sealed record CreatePhotovoltaicDataInput( string? Name, string? Description, string[] Warnings, - OffsetDateTime CreatedAt, + DateTimeOffset CreatedAt, Guid CreatorId, AppliedMethodInput AppliedMethod, RootGetHttpsResourceInput RootResource @@ -104,6 +103,7 @@ public async Task CreatePhotovoltaicDataAsync( IDataByDatabaseAndIdAndKindDataLoader dataByDatabaseAndIdAndKindDataLoader, IDataFormatByIdDataLoader dataFormatByIdDataLoader, ResponseApprovalService responseApprovalService, + IClock clock, CancellationToken cancellationToken ) { @@ -133,6 +133,7 @@ CancellationToken cancellationToken CreatePhotovoltaicDataErrorCode.UNKNOWN_DATA, dataFormatByIdDataLoader, CreatePhotovoltaicDataErrorCode.UNKNOWN_DATA_FORMAT, + clock, cancellationToken ) ).Failed(out var dataFormat, out var validateErrorPayload) @@ -165,4 +166,4 @@ CancellationToken cancellationToken return NewPayload(photovoltaicData, null); } -} +} \ No newline at end of file diff --git a/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataByIdDataLoader.cs b/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataByIdDataLoader.cs deleted file mode 100644 index 79e00ab5..00000000 --- a/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataByIdDataLoader.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.PhotovoltaicDataX; - -public sealed class PhotovoltaicDataByIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : EntityByIdDataLoader( - batchScheduler, - options, - dbContextFactory, - dbContext => dbContext.PhotovoltaicData - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataLoaders.cs b/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataLoaders.cs new file mode 100644 index 00000000..cf51e490 --- /dev/null +++ b/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataLoaders.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace Database.GraphQl.PhotovoltaicDataX; + +public sealed class PhotovoltaicDataLoaders +: DataLoaders +{ + [DataLoader] + public static ValueTask> GetPhotovoltaicDataByIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetEntityByIdAsync( + ids, + databaseContext => databaseContext.PhotovoltaicData, + queryContext, + databaseContextFactory, + cancellationToken + ); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataQueries.cs b/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataQueries.cs index d64b19eb..1f377806 100644 --- a/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataQueries.cs +++ b/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataQueries.cs @@ -1,16 +1,16 @@ using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Database.Authorization; using Database.Data; using Database.GraphQl.DataX; +using Database.GraphQl.Scalars; using Database.Services; using HotChocolate; using HotChocolate.Data; -using HotChocolate.Data.Sorting; using HotChocolate.Resolvers; using HotChocolate.Types; +using NodaTime; namespace Database.GraphQl.PhotovoltaicDataX; @@ -23,11 +23,10 @@ public sealed class PhotovoltaicDataQueries // same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public Task> GetAllPhotovoltaicDataAsync( + public Task> GetAllPhotovoltaicDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, - ISortingContext sorting, IResolverContext resolverContext, CancellationToken cancellationToken ) @@ -36,7 +35,6 @@ CancellationToken cancellationToken context.PhotovoltaicData, locale, accessRightsService, - sorting, resolverContext, cancellationToken ); @@ -47,11 +45,10 @@ CancellationToken cancellationToken // same `id` and when also requesting `uuid`, the latter was always the empty UUID `000...`. [UseFiltering] [UseSorting] - public Task> GetAllPendingPhotovoltaicDataAsync( + public Task> GetAllPendingPhotovoltaicDataAsync( [GraphQLType] string? locale, ApplicationDbContext context, AccessRightsService accessRightsService, - ISortingContext sorting, IResolverContext resolverContext, CommonAuthorization authorization, CancellationToken cancellationToken @@ -61,7 +58,6 @@ CancellationToken cancellationToken context.PhotovoltaicData, locale, accessRightsService, - sorting, resolverContext, authorization, cancellationToken @@ -89,6 +85,7 @@ CancellationToken cancellationToken [GraphQLType] string? locale, PhotovoltaicDataByIdDataLoader byId, AccessRightsService accessRightsService, + IClock clock, CancellationToken cancellationToken ) { diff --git a/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataType.cs b/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataType.cs index 2a445324..ee2a79e9 100644 --- a/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataType.cs +++ b/backend/src/GraphQl/PhotovoltaicDataX/PhotovoltaicDataType.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.PhotovoltaicDataX; public sealed class PhotovoltaicDataType - : DataTypeBase + : DataTypeBase { protected override void Configure( IObjectTypeDescriptor descriptor diff --git a/backend/src/GraphQl/ResponseApprovals/CreateResponseApprovalsMutation.cs b/backend/src/GraphQl/ResponseApprovals/CreateResponseApprovalsMutation.cs index 09643128..e10dd56d 100644 --- a/backend/src/GraphQl/ResponseApprovals/CreateResponseApprovalsMutation.cs +++ b/backend/src/GraphQl/ResponseApprovals/CreateResponseApprovalsMutation.cs @@ -88,7 +88,6 @@ public async Task CreateResponseApprovalsAsync( CancellationToken cancellationToken ) { - var queryContext = resolverContext.GetQueryContext(); if ((await AuthorizeAsync( CreateResponseApprovalsErrorCode.UNAUTHENTICATED, CreateResponseApprovalsErrorCode.UNAUTHORIZED, @@ -103,7 +102,7 @@ CancellationToken cancellationToken var dataSets = new ConcurrentBag(); var errors = new ConcurrentBag(); await Parallel.ForEachAsync( - context.GetAllDataAsync(_ => _.Approval == null, queryContext), + context.GetAllDataAsync(_ => _.Approval == null, resolverContext.GetQueryContext()), cancellationToken, async (data, cancellationToken) => { diff --git a/backend/src/GraphQl/ResponseApprovals/ResponseApprovalFilterType.cs b/backend/src/GraphQl/ResponseApprovals/ResponseApprovalFilterType.cs index 9f2fc9c8..ca198b36 100644 --- a/backend/src/GraphQl/ResponseApprovals/ResponseApprovalFilterType.cs +++ b/backend/src/GraphQl/ResponseApprovals/ResponseApprovalFilterType.cs @@ -5,7 +5,7 @@ namespace Database.GraphQl.ResponseApprovals; public abstract class ResponseApprovalFilterType -: EntityFilterType +: AuditableEntityFilterType { protected override void Configure( IFilterInputTypeDescriptor descriptor @@ -19,7 +19,6 @@ IFilterInputTypeDescriptor descriptor descriptor.Field(x => x.Description); descriptor.Field(x => x.ComponentId); descriptor.Field(x => x.CreatorId); - descriptor.Field(x => x.CreatedAt); descriptor.Field(x => x.AppliedMethod); descriptor.Field(x => x.Approvals); descriptor.Field(x => x.Resources); diff --git a/backend/src/GraphQl/ResponseApprovals/UpdateResponseApprovalsMutation.cs b/backend/src/GraphQl/ResponseApprovals/UpdateResponseApprovalsMutation.cs index 8903d6fa..7c59e799 100644 --- a/backend/src/GraphQl/ResponseApprovals/UpdateResponseApprovalsMutation.cs +++ b/backend/src/GraphQl/ResponseApprovals/UpdateResponseApprovalsMutation.cs @@ -86,7 +86,6 @@ public async Task UpdateResponseApprovalsAsync( CancellationToken cancellationToken ) { - var queryContext = resolverContext.GetQueryContext(); if ((await AuthorizeAsync( UpdateResponseApprovalsErrorCode.UNAUTHENTICATED, UpdateResponseApprovalsErrorCode.UNAUTHORIZED, @@ -102,7 +101,7 @@ CancellationToken cancellationToken var dataSets = new ConcurrentBag(); var errors = new ConcurrentBag(); await Parallel.ForEachAsync( - context.GetAllDataAsync(_ => _.Approval != null, queryContext), + context.GetAllDataAsync(_ => _.Approval != null, resolverContext.GetQueryContext()), cancellationToken, async (data, cancellationToken) => { diff --git a/backend/src/GraphQl/LocaleType.cs b/backend/src/GraphQl/Scalars/LocaleType.cs similarity index 94% rename from backend/src/GraphQl/LocaleType.cs rename to backend/src/GraphQl/Scalars/LocaleType.cs index 0c337de6..1b6f9c86 100644 --- a/backend/src/GraphQl/LocaleType.cs +++ b/backend/src/GraphQl/Scalars/LocaleType.cs @@ -2,7 +2,7 @@ using HotChocolate.Types; // TODO Maybe use an enumeration as runtime type instead of string (and fallback to english when the given one does not exist).namespace Database.GraphQl -namespace Database.GraphQl; +namespace Database.GraphQl.Scalars; /// /// [BCP 47](https://tools.ietf.org/html/bcp47) @@ -29,12 +29,12 @@ public sealed class LocaleType( BindingBehavior bind = BindingBehavior.Explicit) : RegexType( name, - _validationPattern, + ValidationPattern, description, RegexOptions.Compiled | RegexOptions.IgnoreCase, bind) { - private const string _validationPattern = + private const string ValidationPattern = "^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)?(-[a-zA-Z0-9]+)?$"; /// diff --git a/backend/src/GraphQl/Scalars/MyUriType.cs b/backend/src/GraphQl/Scalars/MyUriType.cs new file mode 100644 index 00000000..0d6c2e41 --- /dev/null +++ b/backend/src/GraphQl/Scalars/MyUriType.cs @@ -0,0 +1,110 @@ +using System; +using System.Text.Json; +using HotChocolate.Features; +using HotChocolate.Language; +using HotChocolate.Text.Json; +using Database.GraphQl; +using Microsoft.Extensions.DependencyInjection; +using HotChocolate.Types; + +namespace Database.GraphQl.Scalars; + +// Inspired by https://github.com/ChilliCream/graphql-platform/blob/main/src/HotChocolate/Core/src/Types/Types/Scalars/UriType.cs +/// +/// [RFC 3986](https://tools.ietf.org/html/rfc3986) +/// and +/// [RFC 3987](https://tools.ietf.org/html/rfc3987) +/// compliant +/// [absolute Uniform Resource Locator (URL)](https://tools.ietf.org/html/rfc3986#section-4.3) +/// string with optional +/// [fragment identifier](https://tools.ietf.org/html/rfc3986#section-3.5). +/// [Valid values are for example](https://datatracker.ietf.org/doc/html/rfc3986#section-1.1.2) +/// `ftp://ftp.is.co.za/rfc/rfc1808.txt`, `http://www.ietf.org/rfc/rfc2396.txt`, +/// `ldap://[2001:db8::7]/c=GB?objectClass?one`, `mailto:John.Doe@example.com`, +/// `news:comp.infosystems.www.servers.unix`, `tel:+1-816-555-1212`, +/// `telnet://192.0.2.16:80/`, +/// `urn:oasis:names:specification:docbook:dtd:xml:4.1.2` +/// +/// See also +/// [URL Living Standard](https://url.spec.whatwg.org/#absolute-url-with-fragment-string) +/// and +/// [Identifying resources on the Web](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Identifying_resources_on_the_Web). +/// +/// Specification +public sealed class MyUriType : ScalarType +{ + private const string ScalarName = "Url"; + + private const string SpecifiedByUri = "https://tools.ietf.org/html/rfc3986"; + + public MyUriType( + string name, + string? description = null, + BindingBehavior bind = BindingBehavior.Explicit) + : base(name, bind) + { + Description = description; + SpecifiedBy = new Uri(SpecifiedByUri); + } + + /// + [ActivatorUtilitiesConstructor] + public MyUriType() + : this( + ScalarName, + $"The `{ScalarName}` scalar type represents a Uniform Resource Identifier (URI) as defined by RFC 3986.", + BindingBehavior.Implicit) + { + } + + /// + protected override Uri OnCoerceInputLiteral(StringValueNode valueLiteral) + { + if (TryParseUri(valueLiteral.Value, out var value)) + { + return value; + } + + throw GraphQlThrowHelper.ScalarCannotCoerceInputLiteral(this, valueLiteral); + } + + /// + protected override Uri OnCoerceInputValue(JsonElement inputValue, IFeatureProvider context) + { + if (TryParseUri(inputValue.GetString()!, out var value)) + { + return value; + } + + throw GraphQlThrowHelper.ScalarCannotCoerceInputValue(this, inputValue); + } + + /// + protected override void OnCoerceOutputValue(Uri runtimeValue, ResultElement resultValue) + { + var serialized = runtimeValue.IsAbsoluteUri + ? runtimeValue.AbsoluteUri + : runtimeValue.ToString(); + resultValue.SetStringValue(serialized); + } + + /// + protected override StringValueNode OnValueToLiteral(Uri runtimeValue) + { + var value = runtimeValue.IsAbsoluteUri + ? runtimeValue.AbsoluteUri + : runtimeValue.ToString(); + return new StringValueNode(value); + } + + private static bool TryParseUri(string value, out Uri uri) + { + if (!Uri.TryCreate(value, UriKind.RelativeOrAbsolute, out var parsedUri)) + { + uri = null!; + return false; + } + uri = parsedUri; + return true; + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/Scalars/NonNegativeIntType.cs b/backend/src/GraphQl/Scalars/NonNegativeIntType.cs new file mode 100644 index 00000000..480014f5 --- /dev/null +++ b/backend/src/GraphQl/Scalars/NonNegativeIntType.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using HotChocolate.Language; +using HotChocolate.Text.Json; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace Database.GraphQl.Scalars; + +/// +/// +/// The NonNegativeInt scalar type represents an unsigned 32‐bit numeric +/// non‐fractional value. Response formats that support an unsigned 32‐bit +/// integer or a number type should use that type to represent this scalar. +/// +/// +public sealed class NonNegativeIntType +: IntegerTypeBase +{ + public const string ScalarName = "NonNegativeInt"; + + /// + /// Initializes a new instance of the class. + /// + public NonNegativeIntType(uint min, uint max) + : this( + ScalarName, + $"The `{ScalarName}` scalar type represents an unsigned 32-bit numeric non-fractional value.", + min, + max, + BindingBehavior.Implicit) + { + } + + /// + /// Initializes a new instance of the class. + /// + public NonNegativeIntType( + string name, + string? description = null, + uint min = uint.MinValue, + uint max = uint.MaxValue, + BindingBehavior bind = BindingBehavior.Explicit) + : base(name, min, max, bind) + { + Description = description; + } + + /// + /// Initializes a new instance of the class. + /// + [ActivatorUtilitiesConstructor] + public NonNegativeIntType() + : this(uint.MinValue, uint.MaxValue) + { + } + + /// + protected override uint OnCoerceInputLiteral(IntValueNode valueLiteral) + => valueLiteral.ToUInt32(); + + /// + protected override uint OnCoerceInputValue(JsonElement inputValue) + => inputValue.GetUInt32(); + + /// + protected override void OnCoerceOutputValue(uint runtimeValue, ResultElement resultValue) + => resultValue.SetNumberValue(runtimeValue); + + /// + protected override IValueNode OnValueToLiteral(uint runtimeValue) + => new IntValueNode(runtimeValue); +} \ No newline at end of file diff --git a/backend/src/GraphQl/Sorting.cs b/backend/src/GraphQl/Sorting.cs new file mode 100644 index 00000000..91a8770d --- /dev/null +++ b/backend/src/GraphQl/Sorting.cs @@ -0,0 +1,18 @@ +using GreenDonut.Data; +using Database.Data; + +namespace Database.GraphQl; + +public static class Sorting +{ + public static SortDefinition DefaultEntityOrder( + SortDefinition sort + ) + where TEntity : class, IEntity//, IAuditable + { + // always sort by primary key to make pagination cursors unique + return sort + // .IfEmpty(_ => _.AddDescending(_ => _.CreatedAt)) + .AddDescending(_ => _.Id); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/Users/UserByIdDataLoader.cs b/backend/src/GraphQl/Users/UserByIdDataLoader.cs deleted file mode 100644 index afc10301..00000000 --- a/backend/src/GraphQl/Users/UserByIdDataLoader.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Database.Data; -using Database.GraphQl.Entities; -using GreenDonut; -using Microsoft.EntityFrameworkCore; - -namespace Database.GraphQl.Users; - -public sealed class UserByIdDataLoader( - IBatchScheduler batchScheduler, - DataLoaderOptions options, - IDbContextFactory dbContextFactory - ) - : EntityByIdDataLoader( - batchScheduler, - options, - dbContextFactory, - dbContext => dbContext.Users - ) -{ -} \ No newline at end of file diff --git a/backend/src/GraphQl/Users/UserDataLoaders.cs b/backend/src/GraphQl/Users/UserDataLoaders.cs new file mode 100644 index 00000000..4a4db545 --- /dev/null +++ b/backend/src/GraphQl/Users/UserDataLoaders.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GreenDonut; +using GreenDonut.Data; +using Database.Data; +using Microsoft.EntityFrameworkCore; + +namespace Database.GraphQl.Users; + +public sealed class UserDataLoaders +: DataLoaders +{ + [DataLoader] + public static ValueTask> GetUserByIdAsync( + IReadOnlyList ids, + QueryContext queryContext, + IDbContextFactory databaseContextFactory, + CancellationToken cancellationToken + ) + { + return GetEntityByIdAsync( + ids, + databaseContext => databaseContext.Users, + queryContext, + databaseContextFactory, + cancellationToken + ); + } +} \ No newline at end of file diff --git a/backend/src/GraphQl/Users/UserType.cs b/backend/src/GraphQl/Users/UserType.cs index 08a24a23..d05d62c0 100644 --- a/backend/src/GraphQl/Users/UserType.cs +++ b/backend/src/GraphQl/Users/UserType.cs @@ -1,10 +1,11 @@ using Database.Data; +using Database.GraphQl.Entities; using HotChocolate.Types; namespace Database.GraphQl.Users; public sealed class UserType - : EntityType + : EntityType { protected override void Configure( IObjectTypeDescriptor descriptor diff --git a/backend/src/Jobs/JwtSigningAndEncryptionCertificateRotationJob.cs b/backend/src/Jobs/JwtSigningAndEncryptionCertificateRotationJob.cs index 580efe5f..3165f2ec 100644 --- a/backend/src/Jobs/JwtSigningAndEncryptionCertificateRotationJob.cs +++ b/backend/src/Jobs/JwtSigningAndEncryptionCertificateRotationJob.cs @@ -1,10 +1,11 @@ using System; -using System.Runtime.ConstrainedExecution; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Database.Authentication; +using Database.Extensions; using Microsoft.Extensions.Logging; +using NodaTime; using Quartz; namespace Database.Jobs; @@ -48,6 +49,7 @@ Exception exception } public sealed class JwtSigningAndEncryptionCertificateRotationJob( + IClock clock, ILogger logger ) : IJob @@ -71,7 +73,7 @@ public async Task Execute(IJobExecutionContext context) // TODO: Trigger OpenIddict reload. Currently done dialy with a cron job that restart all services. } - public static X509Certificate2 CreateSigningCertificate(string distinguishedName) + public static X509Certificate2 CreateSigningCertificate(string distinguishedName, IClock clock) { // In the future use ECDSA. // using var algorithm = ECDsa.Create(ECCurve.NamedCurves.nistP256); @@ -93,7 +95,7 @@ public static X509Certificate2 CreateSigningCertificate(string distinguishedName critical: true ) ); - var now = TimeProvider.System.GetUtcNow(); + var now = clock.GetUtcNow().ToDateTimeOffset(); var ephemeralCertificate = request.CreateSelfSigned( notBefore: now.Add(s_notBeforeOffset), notAfter: now.Add(s_notAfterOffset) @@ -110,7 +112,7 @@ public static X509Certificate2 CreateSigningCertificate(string distinguishedName { try { - return CreateSigningCertificate(distinguishedName); + return CreateSigningCertificate(distinguishedName, clock); } catch (Exception exception) { @@ -119,7 +121,7 @@ public static X509Certificate2 CreateSigningCertificate(string distinguishedName } } - public static X509Certificate2 CreateEncryptionCertificate(string distinguishedName) + public static X509Certificate2 CreateEncryptionCertificate(string distinguishedName, IClock clock) { // In the furture use `ML-KEM`. using var algorithm = RSA.Create(keySizeInBits: 3072); @@ -135,7 +137,7 @@ public static X509Certificate2 CreateEncryptionCertificate(string distinguishedN critical: true ) ); - var now = TimeProvider.System.GetUtcNow(); + var now = clock.GetUtcNow().ToDateTimeOffset(); var ephemeralCertificate = request.CreateSelfSigned( notBefore: now.Add(s_notBeforeOffset), notAfter: now.Add(s_notAfterOffset) @@ -152,7 +154,7 @@ public static X509Certificate2 CreateEncryptionCertificate(string distinguishedN { try { - return CreateEncryptionCertificate(distinguishedName); + return CreateEncryptionCertificate(distinguishedName, clock); } catch (Exception exception) { @@ -198,10 +200,10 @@ public void CleanupLongExpiredCertificatesWithErrorHandling(params string[] dist distinguishedName, validOnly: false ); - var now = TimeProvider.System.GetUtcNow(); + var now = clock.GetUtcNow().ToDateTimeOffset(); foreach (var certificate in certificates) { - // Use `NotAfterDaysOffset` as overlap period. + // Use `RefreshTokenLifetime` as overlap period. if (certificate.NotAfter.Add(OpenIdConnectConstants.RefreshTokenLifetime) < now) { try diff --git a/backend/src/Methods/SpectralToIntegral/SpectralToIntegralMethod.cs b/backend/src/Methods/SpectralToIntegral/SpectralToIntegralMethod.cs index 117db51e..367dc4ff 100644 --- a/backend/src/Methods/SpectralToIntegral/SpectralToIntegralMethod.cs +++ b/backend/src/Methods/SpectralToIntegral/SpectralToIntegralMethod.cs @@ -226,7 +226,7 @@ public double Calculate( ImmutableArray<(int wavelength, double weight, double deltaWavelength)> wavelengthsWeights ) { - if (spectralDataPoints == null || spectralDataPoints.Count == 0) + if (spectralDataPoints is null || spectralDataPoints.Count is 0) { throw new ArgumentException("The list `spectralDataPoints` is empty."); } diff --git a/backend/src/Program.cs b/backend/src/Program.cs index 3f99d381..2f644af0 100644 --- a/backend/src/Program.cs +++ b/backend/src/Program.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -using HotChocolate.AspNetCore; using Database.Data; using Database.Services; using Microsoft.AspNetCore.Builder; diff --git a/backend/src/Services/AccessRightsService.cs b/backend/src/Services/AccessRightsService.cs index 6c66b454..2f3c1f5d 100644 --- a/backend/src/Services/AccessRightsService.cs +++ b/backend/src/Services/AccessRightsService.cs @@ -6,6 +6,7 @@ using Database.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using NodaTime; using static Database.ApiRequests.QueryCurrentUserOrInstitution; namespace Database.Services; @@ -27,7 +28,9 @@ public sealed class AccessRightsService( IDbContextFactory dbContextFactory, UserService userService, CacheService cacheService, - ILogger logger) + IClock clock, + ILogger logger +) { public async Task ApplyAccessRightsOnData(T data, CancellationToken cancellationToken) where T : IData @@ -65,7 +68,7 @@ CancellationToken cancellationToken institutionAccessRights = await GetInstitutionAccessRightsAsync(institutionIds, context, cancellationToken); } - var result = ProcessData(data, currentUserOrInstitution.CurrentUser, openIdConnectClientId, institutionIds, institutionAccessRights, alreadyAccessedByUserCount, cacheService); + var result = ProcessData(data, currentUserOrInstitution.CurrentUser, openIdConnectClientId, institutionIds, institutionAccessRights, alreadyAccessedByUserCount); if (context is not null) { @@ -95,8 +98,7 @@ private IEnumerable ProcessData( string? openIdConnectClientId, IReadOnlyList? institutionIds, IReadOnlyList? institutionAccessRights, - uint? alreadyAccessedByUserCount, - CacheService cacheService + uint? alreadyAccessedByUserCount ) where T : IData { @@ -145,7 +147,7 @@ currentUser is null && institutionAccessRights.Any(x => ( x.HasRestrictionsByTime - && x.IsDataRestrictedByTime(dataItem, cacheService, out reason) + && x.IsDataRestrictedByTime(dataItem, clock, cacheService, out reason) ) || ( x.HasRestrictionsByUser diff --git a/backend/src/Services/CacheService.cs b/backend/src/Services/CacheService.cs index 77dca2e3..0a4dc806 100644 --- a/backend/src/Services/CacheService.cs +++ b/backend/src/Services/CacheService.cs @@ -10,7 +10,9 @@ namespace Database.Services; public sealed class CacheService( IMemoryCache currentUserOrInstitutionCache, IMemoryCache accessCountCache, - IMemoryCache timePeriodCountCache) + IMemoryCache timePeriodCountCache, + IClock clock +) { public CurrentUserOrInstitution? SetCurrentUserOrInstitution(string token, CurrentUserOrInstitution cachedUserOrInstitution) { @@ -43,7 +45,7 @@ public uint SetAccessCountForUser(Guid userId, uint count) { if (!timePeriodCountCache.TryGetValue(institutionId, out (OffsetDateTime StartTime, uint Count) accessesPerPeriod)) { - return timePeriodCountCache.Set(institutionId, (OffsetDateTime.UtcNow, (uint)0)); + return timePeriodCountCache.Set(institutionId, (clock.GetUtcNow(), (uint)0)); } return accessesPerPeriod; } @@ -57,6 +59,6 @@ public uint SetAccessCountForUser(Guid userId, uint count) public (OffsetDateTime StartTime, uint Count) SetNewTimePeriod(Guid institutionId) { - return timePeriodCountCache.Set(institutionId, (OffsetDateTime.UtcNow, (uint)0)); + return timePeriodCountCache.Set(institutionId, (clock.GetUtcNow(), (uint)0)); } -} +} \ No newline at end of file diff --git a/backend/src/Services/ResponseApprovalService.cs b/backend/src/Services/ResponseApprovalService.cs index 7d78b304..a5840469 100644 --- a/backend/src/Services/ResponseApprovalService.cs +++ b/backend/src/Services/ResponseApprovalService.cs @@ -32,7 +32,8 @@ public static partial class Log public sealed class ResponseApprovalService( AppSettings appSettings, SigningService signingService, - IRequestExecutorResolver requestExecutorResolver, + IRequestExecutorProvider requestExecutorProvider, + IClock clock, ILogger logger ) { @@ -97,7 +98,7 @@ public async Task CreateResponseApproval(IData dataObject, Can } var (signature, fingerprint) = await signingService.SignData(response); return new ResponseApproval( - OffsetDateTime.UtcNow, + clock.GetUtcNow(), signature, fingerprint, query, @@ -134,7 +135,7 @@ CancellationToken cancellationToken .SetDocument(query) .SetVariableValues(variables) .Build(); - var requestExecutor = await requestExecutorResolver.GetRequestExecutorAsync(cancellationToken: cancellationToken); + var requestExecutor = await requestExecutorProvider.GetExecutorAsync(cancellationToken: cancellationToken); var executionResult = await requestExecutor.ExecuteAsync(operationRequest, cancellationToken); var response = executionResult.ToJson(withIndentations: false); return (query, JsonSerializer.SerializeToElement(variables), response); diff --git a/backend/src/Services/SigningService.cs b/backend/src/Services/SigningService.cs index 299411a2..08b297d1 100644 --- a/backend/src/Services/SigningService.cs +++ b/backend/src/Services/SigningService.cs @@ -171,6 +171,6 @@ private async Task ReceiveKey(string fingerprint) logger.ExecuteCommandOutput(output); logger.ExecuteCommandDiagnostics(diagnostics); logger.ExecuteCommandExitCode(process.ExitCode); - return (process.ExitCode == 0, output, diagnostics); + return (process.ExitCode is 0, output, diagnostics); } } \ No newline at end of file diff --git a/backend/src/Startup.cs b/backend/src/Startup.cs index c1f2c3ca..ab086e01 100644 --- a/backend/src/Startup.cs +++ b/backend/src/Startup.cs @@ -13,7 +13,6 @@ using Database.Enumerations; using Database.GraphQl; using Database.Services; -using HotChocolate.AspNetCore; using Laraue.EfCoreTriggers.PostgreSql.Extensions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; @@ -29,6 +28,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.OpenApi; +using NodaTime; using Npgsql; using OpenTelemetry.Exporter; using OpenTelemetry.Logs; @@ -52,10 +52,10 @@ IConfiguration configuration }) ?? throw new InvalidOperationException("Failed to get application settings from configuration."); + private readonly IClock _clock = SystemClock.Instance; + public void ConfigureServices(IServiceCollection services) { - AuthConfiguration.ConfigureServices(services, environment, _appSettings); - GraphQlConfiguration.ConfigureServices(services, environment); ConfigureDatabaseServices(services); ConfigureRequestResponseServices(services); // ConfigureSessionServices(services); // Not used @@ -75,9 +75,12 @@ public void ConfigureServices(IServiceCollection services) .AddDbContextCheck(); services.AddSingleton(_appSettings); services.AddSingleton(environment); + services.AddSingleton(_clock); // services.AddDatabaseDeveloperPageExceptionFilter(); ConfigureCustomServices(services); ConfigureApiRequests(services); + AuthConfiguration.ConfigureServices(services, environment, _appSettings, _clock); + GraphQlConfiguration.ConfigureServices(services, environment); } private static void ConfigureRequestResponseServices(IServiceCollection services) @@ -85,7 +88,6 @@ private static void ConfigureRequestResponseServices(IServiceCollection services // https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer#forwarded-headers-middleware-order services.Configure(_ => { - // TODO _.AllowedHosts = ... _.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | @@ -169,6 +171,7 @@ IServiceCollection services // { // _.AddAspNetCoreInstrumentation(); // _.AddHttpClientInstrumentation(); + // _.AddHotChocolateInstrumentation(); // _.AddOtlpExporter(_ => // { // _.Endpoint = _appSettings.OpenTelemetry.GrpcUri; @@ -251,6 +254,7 @@ private void ConfigureDatabaseServices(IServiceCollection services) // Configure the database-context options only once in // `AddPooledDbContextFactory` and not a second time in `AddDbContext` // as suggested in + // https://github.com/npgsql/efcore.pg/issues/3375#issuecomment-2509746639 services.AddPooledDbContextFactory(ConfigureDatabaseContext); // Database context as services are used by `OpenIddict`, see in // particular `AuthConfiguration`. @@ -330,11 +334,11 @@ public void Configure(WebApplication app) app.UseStaticFiles(); app.UseCookiePolicy(); // [SameSite cookies](https://learn.microsoft.com/en-us/aspnet/core/security/samesite) app.UseRouting(); - // TODO Do we really want this? See https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization?view=aspnetcore-5.0 + // [Localization](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization) app.UseRequestLocalization(_ => { - _.AddSupportedCultures("en-US", "de-DE"); - _.AddSupportedUICultures("en-US", "de-DE"); + _.AddSupportedCultures("en-US"); + _.AddSupportedUICultures("en-US"); _.SetDefaultCulture("en-US"); }); app.UseCors(); @@ -357,25 +361,6 @@ public void Configure(WebApplication app) _.WithOpenApiRoutePattern(OpenApiConstants.RoutePattern); }); app.MapGraphQL() - .WithOptions( - // https://chillicream.com/docs/hotchocolate/server/middleware - new GraphQLServerOptions - { - EnableSchemaRequests = true, - EnableGetRequests = false, - // AllowedGetOperations = AllowedGetOperations.Query - EnableMultipartRequests = false, - Tool = - { - DisableTelemetry = true, - Enable = true, // environment.IsDevelopment() - IncludeCookies = false, - GraphQLEndpoint = GraphQlConstants.EndpointPath, - HttpMethod = DefaultHttpMethod.Post, - Title = "GraphQL" - } - } - ) .RequireCors(GraphQlConstants.CorsPolicy); app.MapControllers(); app.MapHealthChecks("/health", diff --git a/backend/src/Utilities/FileHelpers.cs b/backend/src/Utilities/FileHelpers.cs index 42b4013b..1bca5e53 100644 --- a/backend/src/Utilities/FileHelpers.cs +++ b/backend/src/Utilities/FileHelpers.cs @@ -1,8 +1,6 @@ using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; -using System.Linq; using System.Net; using System.Reflection; using System.Threading.Tasks; @@ -42,7 +40,7 @@ ModelStateDictionary modelState formFile.FileName); // Check the file length. This check doesn't catch files that only have // a BOM as their content. - if (formFile.Length == 0) + if (formFile.Length is 0) { modelState.AddModelError(formFile.Name, $"{fieldDisplayName}({trustedFileNameForDisplay}) is empty."); @@ -55,7 +53,7 @@ ModelStateDictionary modelState // Check the content length in case the file's only // content was a BOM and the content is actually // empty after removing the BOM. - if (memoryStream.Length == 0) + if (memoryStream.Length is 0) { modelState.AddModelError(formFile.Name, $"{fieldDisplayName}({trustedFileNameForDisplay}) is empty."); @@ -85,7 +83,7 @@ ModelStateDictionary modelState { using var memoryStream = new MemoryStream(); await section.Body.CopyToAsync(memoryStream); - if (memoryStream.Length == 0) + if (memoryStream.Length is 0) { modelState.AddModelError("File", "The file is empty."); } diff --git a/backend/test/AuditableTests.cs b/backend/test/AuditableTests.cs new file mode 100644 index 00000000..a699a184 --- /dev/null +++ b/backend/test/AuditableTests.cs @@ -0,0 +1,75 @@ +using NodaTime; +using NodaTime.Testing; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Database.Data; +using System; +using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; + +namespace Database.Tests; + +[TestFixture] +public sealed class AuditableTests +{ + private DbContextOptions _options = default!; + + [SetUp] + public void Setup() + { + _options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + } + + [Test] + [SuppressMessage("Naming", "CA1707")] + public async Task SaveChanges_SetsAndUpdatesTimestamps_UsingFakeClock() + { + // Arrange + var startInstant = Instant.FromUtc(2024, 1, 1, 10, 0); + var fakeClock = new FakeClock(startInstant); + using var context = new ApplicationDbContext(_options, fakeClock); + var entity = new User("Subject", "Name"); + // Act + context.Add(entity); + await context.SaveChangesAsync(); + // Assert + Assert.Multiple(() => + { + Assert.That(entity.CreatedAt, Is.EqualTo(startInstant.WithOffset(Offset.Zero).ToDateTimeOffset())); + Assert.That(entity.UpdatedAt, Is.EqualTo(startInstant.WithOffset(Offset.Zero).ToDateTimeOffset())); + }); + // Act + var duration = Duration.FromHours(1); + fakeClock.Advance(duration); + var updatedInstant = startInstant.Plus(duration); + entity.Update("New Name"); + await context.SaveChangesAsync(); + // Assert + Assert.Multiple(() => + { + Assert.That(entity.CreatedAt, Is.EqualTo(startInstant.WithOffset(Offset.Zero).ToDateTimeOffset()), "CreatedAt should not change on update."); + Assert.That(entity.UpdatedAt, Is.EqualTo(updatedInstant.WithOffset(Offset.Zero).ToDateTimeOffset()), "UpdatedAt should reflect the new fake time."); + }); + } + + // [Test] + // public async Task Remove_PerformsSoftDelete_AndSetsDeletedAt() + // { + // var deleteTime = Instant.FromUtc(2024, 1, 1, 15, 0); + // var fakeClock = new FakeClock(deleteTime); + // using var context = new ApplicationDbContext(_options, fakeClock); + // var entity = new YourModel { Name = "To Be Deleted" }; + // context.Add(entity); + // await context.SaveChangesAsync(); + // // Act + // context.Remove(entity); + // await context.SaveChangesAsync(); + // // Assert + // Assert.That(entity.DeletedAt, Is.EqualTo(deleteTime)); + // // Ensure it's hidden from normal queries + // var count = await context.YourModels.CountAsync(); + // Assert.That(count, Is.Zero); + // } +} \ No newline at end of file diff --git a/backend/test/Database.Tests.csproj b/backend/test/Database.Tests.csproj index 01e4126b..e052358d 100644 --- a/backend/test/Database.Tests.csproj +++ b/backend/test/Database.Tests.csproj @@ -9,21 +9,23 @@ - + - - - + + + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - +