diff --git a/code/session-7/.config/dotnet-tools.json b/code/session-7/.config/dotnet-tools.json index c735fef..8612f23 100644 --- a/code/session-7/.config/dotnet-tools.json +++ b/code/session-7/.config/dotnet-tools.json @@ -3,10 +3,11 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "5.0.0", + "version": "8.0.7", "commands": [ "dotnet-ef" - ] + ], + "rollForward": false } } } \ No newline at end of file diff --git a/code/session-7/.vscode/launch.json b/code/session-7/.vscode/launch.json deleted file mode 100644 index e90375e..0000000 --- a/code/session-7/.vscode/launch.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": ".NET Core Launch (web)", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "build", - "program": "${workspaceFolder}/GraphQL/bin/Debug/net5.0/GraphQL.dll", - "args": [], - "cwd": "${workspaceFolder}/GraphQL", - "stopAtEntry": false, - "serverReadyAction": { - "action": "openExternally", - "pattern": "\\bNow listening on:\\s+(https?://\\S+)" - }, - "env": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "sourceFileMap": { - "/Views": "${workspaceFolder}/Views" - } - }, - { - "name": ".NET Core Attach", - "type": "coreclr", - "request": "attach", - "processId": "${command:pickProcess}" - } - ] -} \ No newline at end of file diff --git a/code/session-7/.vscode/tasks.json b/code/session-7/.vscode/tasks.json deleted file mode 100644 index 31c32bd..0000000 --- a/code/session-7/.vscode/tasks.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", - "tasks": [ - { - "label": "build", - "command": "dotnet", - "type": "shell", - "args": [ - "build", - // Ask dotnet build to generate full paths for file names. - "/property:GenerateFullPaths=true", - // Do not generate summary otherwise it leads to duplicate errors in Problems panel - "/consoleloggerparameters:NoSummary" - ], - "group": "build", - "presentation": { - "reveal": "silent" - }, - "problemMatcher": "$msCompile" - } - ] -} \ No newline at end of file diff --git a/code/session-7/ConferencePlanner.sln b/code/session-7/ConferencePlanner.sln index 42fadb8..8c414fc 100644 --- a/code/session-7/ConferencePlanner.sln +++ b/code/session-7/ConferencePlanner.sln @@ -1,34 +1,22 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26124.0 -MinimumVisualStudioVersion = 15.0.26124.0 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphQL", "GraphQL\GraphQL.csproj", "{48385280-56F1-4937-9655-E6A79184740B}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {48385280-56F1-4937-9655-E6A79184740B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {48385280-56F1-4937-9655-E6A79184740B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {48385280-56F1-4937-9655-E6A79184740B}.Debug|x64.ActiveCfg = Debug|Any CPU - {48385280-56F1-4937-9655-E6A79184740B}.Debug|x64.Build.0 = Debug|Any CPU - {48385280-56F1-4937-9655-E6A79184740B}.Debug|x86.ActiveCfg = Debug|Any CPU - {48385280-56F1-4937-9655-E6A79184740B}.Debug|x86.Build.0 = Debug|Any CPU - {48385280-56F1-4937-9655-E6A79184740B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {48385280-56F1-4937-9655-E6A79184740B}.Release|Any CPU.Build.0 = Release|Any CPU - {48385280-56F1-4937-9655-E6A79184740B}.Release|x64.ActiveCfg = Release|Any CPU - {48385280-56F1-4937-9655-E6A79184740B}.Release|x64.Build.0 = Release|Any CPU - {48385280-56F1-4937-9655-E6A79184740B}.Release|x86.ActiveCfg = Release|Any CPU - {48385280-56F1-4937-9655-E6A79184740B}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphQL", "GraphQL\GraphQL.csproj", "{D96823B9-86D3-4D54-A803-F1D43AEBE1FD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D96823B9-86D3-4D54-A803-F1D43AEBE1FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D96823B9-86D3-4D54-A803-F1D43AEBE1FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D96823B9-86D3-4D54-A803-F1D43AEBE1FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D96823B9-86D3-4D54-A803-F1D43AEBE1FD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/code/session-7/GraphQL/Attendees/AttendeeDataLoaders.cs b/code/session-7/GraphQL/Attendees/AttendeeDataLoaders.cs new file mode 100644 index 0000000..c3d8199 --- /dev/null +++ b/code/session-7/GraphQL/Attendees/AttendeeDataLoaders.cs @@ -0,0 +1,18 @@ +using ConferencePlanner.GraphQL.Data; +using Microsoft.EntityFrameworkCore; + +namespace ConferencePlanner.GraphQL.Attendees; + +public static class AttendeeDataLoaders +{ + [DataLoader] + public static async Task> AttendeeByIdAsync( + IReadOnlyList ids, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) + { + return await dbContext.Attendees + .Where(a => ids.Contains(a.Id)) + .ToDictionaryAsync(a => a.Id, cancellationToken); + } +} diff --git a/code/session-7/GraphQL/Attendees/AttendeeExceptions.cs b/code/session-7/GraphQL/Attendees/AttendeeExceptions.cs new file mode 100644 index 0000000..59fe765 --- /dev/null +++ b/code/session-7/GraphQL/Attendees/AttendeeExceptions.cs @@ -0,0 +1,3 @@ +namespace ConferencePlanner.GraphQL.Attendees; + +public sealed class AttendeeNotFoundException() : Exception("Attendee not found."); diff --git a/code/session-7/GraphQL/Attendees/AttendeeMutations.cs b/code/session-7/GraphQL/Attendees/AttendeeMutations.cs index 81d1e02..6496fed 100644 --- a/code/session-7/GraphQL/Attendees/AttendeeMutations.cs +++ b/code/session-7/GraphQL/Attendees/AttendeeMutations.cs @@ -1,68 +1,56 @@ -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using ConferencePlanner.GraphQL.Common; using ConferencePlanner.GraphQL.Data; -using HotChocolate; -using HotChocolate.Types; using HotChocolate.Subscriptions; +using Microsoft.EntityFrameworkCore; + +namespace ConferencePlanner.GraphQL.Attendees; -namespace ConferencePlanner.GraphQL.Attendees +[MutationType] +public static class AttendeeMutations { - [ExtendObjectType(Name = "Mutation")] - public class AttendeeMutations + public static async Task RegisterAttendeeAsync( + RegisterAttendeeInput input, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) { - [UseApplicationDbContext] - public async Task RegisterAttendeeAsync( - RegisterAttendeeInput input, - [ScopedService] ApplicationDbContext context, - CancellationToken cancellationToken) + var attendee = new Attendee { - var attendee = new Attendee - { - FirstName = input.FirstName, - LastName = input.LastName, - UserName = input.UserName, - EmailAddress = input.EmailAddress - }; + FirstName = input.FirstName, + LastName = input.LastName, + Username = input.Username, + EmailAddress = input.EmailAddress + }; - context.Attendees.Add(attendee); + dbContext.Attendees.Add(attendee); - await context.SaveChangesAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); - return new RegisterAttendeePayload(attendee); - } + return attendee; + } - [UseApplicationDbContext] - public async Task CheckInAttendeeAsync( - CheckInAttendeeInput input, - [ScopedService] ApplicationDbContext context, - [Service] ITopicEventSender eventSender, - CancellationToken cancellationToken) - { - Attendee attendee = await context.Attendees.FirstOrDefaultAsync( - t => t.Id == input.AttendeeId, cancellationToken); + public static async Task CheckInAttendeeAsync( + CheckInAttendeeInput input, + ApplicationDbContext dbContext, + ITopicEventSender eventSender, + CancellationToken cancellationToken) + { + var attendee = await dbContext.Attendees.FirstOrDefaultAsync( + a => a.Id == input.AttendeeId, + cancellationToken); - if (attendee is null) - { - return new CheckInAttendeePayload( - new UserError("Attendee not found.", "ATTENDEE_NOT_FOUND")); - } + if (attendee is null) + { + throw new AttendeeNotFoundException(); + } - attendee.SessionsAttendees.Add( - new SessionAttendee - { - SessionId = input.SessionId - }); + attendee.SessionsAttendees.Add(new SessionAttendee { SessionId = input.SessionId }); - await context.SaveChangesAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); - await eventSender.SendAsync( - "OnAttendeeCheckedIn_" + input.SessionId, - input.AttendeeId, - cancellationToken); + await eventSender.SendAsync( + $"OnAttendeeCheckedIn_{input.SessionId}", + input.AttendeeId, + cancellationToken); - return new CheckInAttendeePayload(attendee, input.SessionId); - } + return attendee; } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Attendees/AttendeePayloadBase.cs b/code/session-7/GraphQL/Attendees/AttendeePayloadBase.cs deleted file mode 100644 index 4b98558..0000000 --- a/code/session-7/GraphQL/Attendees/AttendeePayloadBase.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using ConferencePlanner.GraphQL.Common; -using ConferencePlanner.GraphQL.Data; - -namespace ConferencePlanner.GraphQL.Attendees -{ - public class AttendeePayloadBase : Payload - { - protected AttendeePayloadBase(Attendee attendee) - { - Attendee = attendee; - } - - protected AttendeePayloadBase(IReadOnlyList errors) - : base(errors) - { - } - - public Attendee? Attendee { get; } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Attendees/AttendeeQueries.cs b/code/session-7/GraphQL/Attendees/AttendeeQueries.cs index e877335..b653b8f 100644 --- a/code/session-7/GraphQL/Attendees/AttendeeQueries.cs +++ b/code/session-7/GraphQL/Attendees/AttendeeQueries.cs @@ -1,34 +1,30 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; -using HotChocolate.Types; -using HotChocolate.Types.Relay; -namespace ConferencePlanner.GraphQL.Attendees +namespace ConferencePlanner.GraphQL.Attendees; + +[QueryType] +public static class AttendeeQueries { - [ExtendObjectType(Name = "Query")] - public class AttendeeQueries + [UsePaging] + public static IQueryable GetAttendees(ApplicationDbContext dbContext) { - [UseApplicationDbContext] - [UsePaging] - public IQueryable GetAttendees( - [ScopedService] ApplicationDbContext context) => - context.Attendees; + return dbContext.Attendees.OrderBy(a => a.Username); + } - public Task GetAttendeeByIdAsync( - [ID(nameof(Attendee))] int id, - AttendeeByIdDataLoader attendeeById, - CancellationToken cancellationToken) => - attendeeById.LoadAsync(id, cancellationToken); + [NodeResolver] + public static async Task GetAttendeeByIdAsync( + int id, + AttendeeByIdDataLoader attendeeById, + CancellationToken cancellationToken) + { + return await attendeeById.LoadAsync(id, cancellationToken); + } - public async Task> GetAttendeesByIdAsync( - [ID(nameof(Attendee))] int[] ids, - AttendeeByIdDataLoader attendeeById, - CancellationToken cancellationToken) => - await attendeeById.LoadAsync(ids, cancellationToken); + public static async Task> GetAttendeesByIdAsync( + [ID] int[] ids, + AttendeeByIdDataLoader attendeeById, + CancellationToken cancellationToken) + { + return await attendeeById.LoadAsync(ids, cancellationToken); } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Attendees/AttendeeSubscriptions.cs b/code/session-7/GraphQL/Attendees/AttendeeSubscriptions.cs index 475098e..5d35833 100644 --- a/code/session-7/GraphQL/Attendees/AttendeeSubscriptions.cs +++ b/code/session-7/GraphQL/Attendees/AttendeeSubscriptions.cs @@ -1,31 +1,27 @@ -using System.Threading; -using System.Threading.Tasks; using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; using HotChocolate.Execution; using HotChocolate.Subscriptions; -using HotChocolate.Types; -using HotChocolate.Types.Relay; -namespace ConferencePlanner.GraphQL.Attendees +namespace ConferencePlanner.GraphQL.Attendees; + +[SubscriptionType] +public static class AttendeeSubscriptions { - [ExtendObjectType(Name = "Subscription")] - public class AttendeeSubscriptions + [Subscribe(With = nameof(SubscribeToOnAttendeeCheckedInAsync))] + public static SessionAttendeeCheckIn OnAttendeeCheckedIn( + [ID] int sessionId, + [EventMessage] int attendeeId) { - [Subscribe(With = nameof(SubscribeToOnAttendeeCheckedInAsync))] - public SessionAttendeeCheckIn OnAttendeeCheckedIn( - [ID(nameof(Session))] int sessionId, - [EventMessage] int attendeeId, - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) => - new SessionAttendeeCheckIn(attendeeId, sessionId); + return new SessionAttendeeCheckIn(attendeeId, sessionId); + } - public async ValueTask> SubscribeToOnAttendeeCheckedInAsync( - int sessionId, - [Service] ITopicEventReceiver eventReceiver, - CancellationToken cancellationToken) => - await eventReceiver.SubscribeAsync( - "OnAttendeeCheckedIn_" + sessionId, cancellationToken); + public static async ValueTask> SubscribeToOnAttendeeCheckedInAsync( + int sessionId, + ITopicEventReceiver eventReceiver, + CancellationToken cancellationToken) + { + return await eventReceiver.SubscribeAsync( + $"OnAttendeeCheckedIn_{sessionId}", + cancellationToken); } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Attendees/AttendeeType.cs b/code/session-7/GraphQL/Attendees/AttendeeType.cs new file mode 100644 index 0000000..2d627cb --- /dev/null +++ b/code/session-7/GraphQL/Attendees/AttendeeType.cs @@ -0,0 +1,37 @@ +using ConferencePlanner.GraphQL.Data; +using ConferencePlanner.GraphQL.Sessions; +using Microsoft.EntityFrameworkCore; + +namespace ConferencePlanner.GraphQL.Attendees; + +[ObjectType] +public static partial class AttendeeType +{ + static partial void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .ImplementsNode() + .IdField(a => a.Id) + .ResolveNode( + async (ctx, id) + => await ctx.DataLoader() + .LoadAsync(id, ctx.RequestAborted)); + + descriptor.Ignore(a => a.SessionsAttendees); + } + + public static async Task> GetSessionsAsync( + [Parent] Attendee attendee, + ApplicationDbContext dbContext, + SessionByIdDataLoader sessionById, + CancellationToken cancellationToken) + { + var sessionIds = await dbContext.Attendees + .Where(a => a.Id == attendee.Id) + .Include(a => a.SessionsAttendees) + .SelectMany(a => a.SessionsAttendees.Select(sa => sa.SessionId)) + .ToArrayAsync(cancellationToken); + + return await sessionById.LoadAsync(sessionIds, cancellationToken); + } +} diff --git a/code/session-7/GraphQL/Attendees/CheckInAttendeeInput.cs b/code/session-7/GraphQL/Attendees/CheckInAttendeeInput.cs index 5464c22..8f52b73 100644 --- a/code/session-7/GraphQL/Attendees/CheckInAttendeeInput.cs +++ b/code/session-7/GraphQL/Attendees/CheckInAttendeeInput.cs @@ -1,11 +1,7 @@ using ConferencePlanner.GraphQL.Data; -using HotChocolate.Types.Relay; -namespace ConferencePlanner.GraphQL.Attendees -{ - public record CheckInAttendeeInput( - [ID(nameof(Session))] - int SessionId, - [ID(nameof(Attendee))] - int AttendeeId); -} \ No newline at end of file +namespace ConferencePlanner.GraphQL.Attendees; + +public sealed record CheckInAttendeeInput( + [property: ID] int SessionId, + [property: ID] int AttendeeId); diff --git a/code/session-7/GraphQL/Attendees/CheckInAttendeePayload.cs b/code/session-7/GraphQL/Attendees/CheckInAttendeePayload.cs deleted file mode 100644 index 29e1276..0000000 --- a/code/session-7/GraphQL/Attendees/CheckInAttendeePayload.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using ConferencePlanner.GraphQL.Common; -using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; - -namespace ConferencePlanner.GraphQL.Attendees -{ - public class CheckInAttendeePayload : AttendeePayloadBase - { - private int? _sessionId; - - public CheckInAttendeePayload(Attendee attendee, int sessionId) - : base(attendee) - { - _sessionId = sessionId; - } - - public CheckInAttendeePayload(UserError error) - : base(new[] { error }) - { - } - - public async Task GetSessionAsync( - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) - { - if (_sessionId.HasValue) - { - return await sessionById.LoadAsync(_sessionId.Value, cancellationToken); - } - - return null; - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Attendees/RegisterAttendeeInput.cs b/code/session-7/GraphQL/Attendees/RegisterAttendeeInput.cs index 1710f0b..4b40751 100644 --- a/code/session-7/GraphQL/Attendees/RegisterAttendeeInput.cs +++ b/code/session-7/GraphQL/Attendees/RegisterAttendeeInput.cs @@ -1,8 +1,7 @@ -namespace ConferencePlanner.GraphQL.Attendees -{ - public record RegisterAttendeeInput( - string FirstName, - string LastName, - string UserName, - string EmailAddress); -} \ No newline at end of file +namespace ConferencePlanner.GraphQL.Attendees; + +public sealed record RegisterAttendeeInput( + string FirstName, + string LastName, + string Username, + string EmailAddress); diff --git a/code/session-7/GraphQL/Attendees/RegisterAttendeePayload.cs b/code/session-7/GraphQL/Attendees/RegisterAttendeePayload.cs deleted file mode 100644 index a79e99a..0000000 --- a/code/session-7/GraphQL/Attendees/RegisterAttendeePayload.cs +++ /dev/null @@ -1,18 +0,0 @@ -using ConferencePlanner.GraphQL.Common; -using ConferencePlanner.GraphQL.Data; - -namespace ConferencePlanner.GraphQL.Attendees -{ - public class RegisterAttendeePayload : AttendeePayloadBase - { - public RegisterAttendeePayload(Attendee attendee) - : base(attendee) - { - } - - public RegisterAttendeePayload(UserError error) - : base(new[] { error }) - { - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Attendees/SessionAttendeeCheckIn.cs b/code/session-7/GraphQL/Attendees/SessionAttendeeCheckIn.cs index af3c161..cf265fe 100644 --- a/code/session-7/GraphQL/Attendees/SessionAttendeeCheckIn.cs +++ b/code/session-7/GraphQL/Attendees/SessionAttendeeCheckIn.cs @@ -1,45 +1,38 @@ -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; -using HotChocolate.Types.Relay; +using ConferencePlanner.GraphQL.Sessions; -namespace ConferencePlanner.GraphQL.Attendees -{ - public class SessionAttendeeCheckIn - { - public SessionAttendeeCheckIn(int attendeeId, int sessionId) - { - AttendeeId = attendeeId; - SessionId = sessionId; - } +namespace ConferencePlanner.GraphQL.Attendees; - [ID(nameof(Attendee))] - public int AttendeeId { get; } +public sealed class SessionAttendeeCheckIn(int attendeeId, int sessionId) +{ + [ID] + public int AttendeeId { get; } = attendeeId; - [ID(nameof(Session))] - public int SessionId { get; } + [ID] + public int SessionId { get; } = sessionId; - [UseApplicationDbContext] - public async Task CheckInCountAsync( - [ScopedService] ApplicationDbContext context, - CancellationToken cancellationToken) => - await context.Sessions - .Where(session => session.Id == SessionId) - .SelectMany(session => session.SessionAttendees) - .CountAsync(cancellationToken); + public async Task CheckInCountAsync( + ApplicationDbContext dbContext, + CancellationToken cancellationToken) + { + return await dbContext.Sessions + .Where(s => s.Id == SessionId) + .SelectMany(s => s.SessionAttendees) + .CountAsync(cancellationToken); + } - public Task GetAttendeeAsync( - AttendeeByIdDataLoader attendeeById, - CancellationToken cancellationToken) => - attendeeById.LoadAsync(AttendeeId, cancellationToken); + public async Task GetAttendeeAsync( + AttendeeByIdDataLoader attendeeById, + CancellationToken cancellationToken) + { + return await attendeeById.LoadAsync(AttendeeId, cancellationToken); + } - public Task GetSessionAsync( - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) => - sessionById.LoadAsync(AttendeeId, cancellationToken); + public async Task GetSessionAsync( + SessionByIdDataLoader sessionById, + CancellationToken cancellationToken) + { + return await sessionById.LoadAsync(SessionId, cancellationToken); } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Common/Payload.cs b/code/session-7/GraphQL/Common/Payload.cs deleted file mode 100644 index e9d2839..0000000 --- a/code/session-7/GraphQL/Common/Payload.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; - -namespace ConferencePlanner.GraphQL.Common -{ - public abstract class Payload - { - protected Payload(IReadOnlyList? errors = null) - { - Errors = errors; - } - - public IReadOnlyList? Errors { get; } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Common/UserError.cs b/code/session-7/GraphQL/Common/UserError.cs deleted file mode 100644 index 3d587dd..0000000 --- a/code/session-7/GraphQL/Common/UserError.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace ConferencePlanner.GraphQL.Common -{ - public class UserError - { - public UserError(string message, string code) - { - Message = message; - Code = code; - } - - public string Message { get; } - - public string Code { get; } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Data/ApplicationDbContext.cs b/code/session-7/GraphQL/Data/ApplicationDbContext.cs index bbd7dda..2093513 100644 --- a/code/session-7/GraphQL/Data/ApplicationDbContext.cs +++ b/code/session-7/GraphQL/Data/ApplicationDbContext.cs @@ -1,38 +1,33 @@ - using Microsoft.EntityFrameworkCore; - - namespace ConferencePlanner.GraphQL.Data - { - public class ApplicationDbContext : DbContext - { - public ApplicationDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder - .Entity() - .HasIndex(a => a.UserName) - .IsUnique(); - - // Many-to-many: Session <-> Attendee - modelBuilder - .Entity() - .HasKey(ca => new { ca.SessionId, ca.AttendeeId }); - - // Many-to-many: Speaker <-> Session - modelBuilder - .Entity() - .HasKey(ss => new { ss.SessionId, ss.SpeakerId }); - } - - public DbSet Sessions { get; set; } = default!; - - public DbSet Tracks { get; set; } = default!; - - public DbSet Speakers { get; set; } = default!; - - public DbSet Attendees { get; set; } = default!; - } - } \ No newline at end of file +using Microsoft.EntityFrameworkCore; + +namespace ConferencePlanner.GraphQL.Data; + +public sealed class ApplicationDbContext(DbContextOptions options) + : DbContext(options) +{ + public DbSet Attendees { get; set; } + + public DbSet Sessions { get; set; } + + public DbSet Speakers { get; set; } + + public DbSet Tracks { get; set; } = default!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder + .Entity() + .HasIndex(a => a.Username) + .IsUnique(); + + // Many-to-many: Session <-> Attendee + modelBuilder + .Entity() + .HasKey(sa => new { sa.SessionId, sa.AttendeeId }); + + // Many-to-many: Speaker <-> Session + modelBuilder + .Entity() + .HasKey(ss => new { ss.SessionId, ss.SpeakerId }); + } +} diff --git a/code/session-7/GraphQL/Data/Attendee.cs b/code/session-7/GraphQL/Data/Attendee.cs index e3f9ab0..87f0e54 100644 --- a/code/session-7/GraphQL/Data/Attendee.cs +++ b/code/session-7/GraphQL/Data/Attendee.cs @@ -1,28 +1,23 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -namespace ConferencePlanner.GraphQL.Data +namespace ConferencePlanner.GraphQL.Data; + +public sealed class Attendee { - public class Attendee - { - public int Id { get; set; } + public int Id { get; set; } - [Required] - [StringLength(200)] - public string? FirstName { get; set; } + [StringLength(200)] + public required string FirstName { get; set; } - [Required] - [StringLength(200)] - public string? LastName { get; set; } + [StringLength(200)] + public required string LastName { get; set; } - [Required] - [StringLength(200)] - public string? UserName { get; set; } + [StringLength(200)] + public required string Username { get; set; } - [StringLength(256)] - public string? EmailAddress { get; set; } + [StringLength(256)] + public string? EmailAddress { get; set; } - public ICollection SessionsAttendees { get; set; } = - new List(); - } -} \ No newline at end of file + public ICollection SessionsAttendees { get; set; } = + new List(); +} diff --git a/code/session-7/GraphQL/Data/Session.cs b/code/session-7/GraphQL/Data/Session.cs index b340977..bb4dd1b 100644 --- a/code/session-7/GraphQL/Data/Session.cs +++ b/code/session-7/GraphQL/Data/Session.cs @@ -1,37 +1,33 @@ -using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -namespace ConferencePlanner.GraphQL.Data +namespace ConferencePlanner.GraphQL.Data; + +public sealed class Session { - public class Session - { - public int Id { get; set; } + public int Id { get; set; } - [Required] - [StringLength(200)] - public string? Title { get; set; } + [StringLength(200)] + public required string Title { get; set; } - [StringLength(4000)] - public string? Abstract { get; set; } + [StringLength(4000)] + public string? Abstract { get; set; } - public DateTimeOffset? StartTime { get; set; } + public DateTimeOffset? StartTime { get; set; } - public DateTimeOffset? EndTime { get; set; } + public DateTimeOffset? EndTime { get; set; } - // Bonus points to those who can figure out why this is written this way - public TimeSpan Duration => - EndTime?.Subtract(StartTime ?? EndTime ?? DateTimeOffset.MinValue) ?? - TimeSpan.Zero; + // Bonus points to those who can figure out why this is written this way. + public TimeSpan Duration => + EndTime?.Subtract(StartTime ?? EndTime ?? DateTimeOffset.MinValue) ?? + TimeSpan.Zero; - public int? TrackId { get; set; } + public int? TrackId { get; set; } - public ICollection SessionSpeakers { get; set; } = - new List(); + public ICollection SessionSpeakers { get; set; } = + new List(); - public ICollection SessionAttendees { get; set; } = - new List(); + public ICollection SessionAttendees { get; set; } = + new List(); - public Track? Track { get; set; } - } -} \ No newline at end of file + public Track? Track { get; set; } +} diff --git a/code/session-7/GraphQL/Data/SessionAttendee.cs b/code/session-7/GraphQL/Data/SessionAttendee.cs index 089c71a..fb44531 100644 --- a/code/session-7/GraphQL/Data/SessionAttendee.cs +++ b/code/session-7/GraphQL/Data/SessionAttendee.cs @@ -1,13 +1,12 @@ -namespace ConferencePlanner.GraphQL.Data +namespace ConferencePlanner.GraphQL.Data; + +public sealed class SessionAttendee { - public class SessionAttendee - { - public int SessionId { get; set; } + public int SessionId { get; set; } - public Session? Session { get; set; } + public Session? Session { get; set; } - public int AttendeeId { get; set; } + public int AttendeeId { get; set; } - public Attendee? Attendee { get; set; } - } -} \ No newline at end of file + public Attendee? Attendee { get; set; } +} diff --git a/code/session-7/GraphQL/Data/SessionSpeaker.cs b/code/session-7/GraphQL/Data/SessionSpeaker.cs index ed83e86..196a00a 100644 --- a/code/session-7/GraphQL/Data/SessionSpeaker.cs +++ b/code/session-7/GraphQL/Data/SessionSpeaker.cs @@ -1,13 +1,12 @@ -namespace ConferencePlanner.GraphQL.Data +namespace ConferencePlanner.GraphQL.Data; + +public sealed class SessionSpeaker { - public class SessionSpeaker - { - public int SessionId { get; set; } + public int SessionId { get; set; } - public Session? Session { get; set; } + public Session? Session { get; set; } - public int SpeakerId { get; set; } + public int SpeakerId { get; set; } - public Speaker? Speaker { get; set; } - } -} \ No newline at end of file + public Speaker? Speaker { get; set; } +} diff --git a/code/session-7/GraphQL/Data/Speaker.cs b/code/session-7/GraphQL/Data/Speaker.cs index 0943514..661fd88 100644 --- a/code/session-7/GraphQL/Data/Speaker.cs +++ b/code/session-7/GraphQL/Data/Speaker.cs @@ -1,23 +1,20 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -namespace ConferencePlanner.GraphQL.Data +namespace ConferencePlanner.GraphQL.Data; + +public sealed class Speaker { - public class Speaker - { - public int Id { get; set; } + public int Id { get; set; } - [Required] - [StringLength(200)] - public string? Name { get; set; } + [StringLength(200)] + public required string Name { get; set; } - [StringLength(4000)] - public string? Bio { get; set; } + [StringLength(4000)] + public string? Bio { get; set; } - [StringLength(1000)] - public string? WebSite { get; set; } + [StringLength(1000)] + public string? Website { get; set; } - public ICollection SessionSpeakers { get; set; } = - new List(); - } - } \ No newline at end of file + public ICollection SessionSpeakers { get; set; } = + new List(); +} diff --git a/code/session-7/GraphQL/Data/Track.cs b/code/session-7/GraphQL/Data/Track.cs index f2392b6..0fa4429 100644 --- a/code/session-7/GraphQL/Data/Track.cs +++ b/code/session-7/GraphQL/Data/Track.cs @@ -1,17 +1,14 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -namespace ConferencePlanner.GraphQL.Data +namespace ConferencePlanner.GraphQL.Data; + +public sealed class Track { - public class Track - { - public int Id { get; set; } + public int Id { get; set; } - [Required] - [StringLength(200)] - public string? Name { get; set; } + [StringLength(200)] + public required string Name { get; set; } - public ICollection Sessions { get; set; } = - new List(); - } -} \ No newline at end of file + public ICollection Sessions { get; set; } = + new List(); +} diff --git a/code/session-7/GraphQL/DataLoader/AttendeeByIdDataLoader.cs b/code/session-7/GraphQL/DataLoader/AttendeeByIdDataLoader.cs deleted file mode 100644 index a2bdbd0..0000000 --- a/code/session-7/GraphQL/DataLoader/AttendeeByIdDataLoader.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using ConferencePlanner.GraphQL.Data; -using GreenDonut; -using HotChocolate.DataLoader; - -namespace ConferencePlanner.GraphQL.DataLoader -{ - public class AttendeeByIdDataLoader : BatchDataLoader - { - private readonly IDbContextFactory _dbContextFactory; - - public AttendeeByIdDataLoader( - IBatchScheduler batchScheduler, - IDbContextFactory dbContextFactory) - : base(batchScheduler) - { - _dbContextFactory = dbContextFactory ?? - throw new ArgumentNullException(nameof(dbContextFactory)); - } - - protected override async Task> LoadBatchAsync( - IReadOnlyList keys, - CancellationToken cancellationToken) - { - await using ApplicationDbContext dbContext = - _dbContextFactory.CreateDbContext(); - - return await dbContext.Attendees - .Where(s => keys.Contains(s.Id)) - .ToDictionaryAsync(t => t.Id, cancellationToken); - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/DataLoader/SessionByIdDataLoader.cs b/code/session-7/GraphQL/DataLoader/SessionByIdDataLoader.cs deleted file mode 100644 index dbd675b..0000000 --- a/code/session-7/GraphQL/DataLoader/SessionByIdDataLoader.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using ConferencePlanner.GraphQL.Data; -using GreenDonut; -using HotChocolate.DataLoader; - -namespace ConferencePlanner.GraphQL.DataLoader -{ - public class SessionByIdDataLoader : BatchDataLoader - { - private readonly IDbContextFactory _dbContextFactory; - - public SessionByIdDataLoader( - IBatchScheduler batchScheduler, - IDbContextFactory dbContextFactory) - : base(batchScheduler) - { - _dbContextFactory = dbContextFactory ?? - throw new ArgumentNullException(nameof(dbContextFactory)); - } - - protected override async Task> LoadBatchAsync( - IReadOnlyList keys, - CancellationToken cancellationToken) - { - await using ApplicationDbContext dbContext = - _dbContextFactory.CreateDbContext(); - - return await dbContext.Sessions - .Where(s => keys.Contains(s.Id)) - .ToDictionaryAsync(t => t.Id, cancellationToken); - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/DataLoader/SpeakerByIdDataLoader.cs b/code/session-7/GraphQL/DataLoader/SpeakerByIdDataLoader.cs deleted file mode 100644 index 44d8208..0000000 --- a/code/session-7/GraphQL/DataLoader/SpeakerByIdDataLoader.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using ConferencePlanner.GraphQL.Data; -using GreenDonut; -using HotChocolate.DataLoader; - -namespace ConferencePlanner.GraphQL.DataLoader -{ - public class SpeakerByIdDataLoader : BatchDataLoader - { - private readonly IDbContextFactory _dbContextFactory; - - public SpeakerByIdDataLoader( - IBatchScheduler batchScheduler, - IDbContextFactory dbContextFactory) - : base(batchScheduler) - { - _dbContextFactory = dbContextFactory ?? - throw new ArgumentNullException(nameof(dbContextFactory)); - } - - protected override async Task> LoadBatchAsync( - IReadOnlyList keys, - CancellationToken cancellationToken) - { - await using ApplicationDbContext dbContext = - _dbContextFactory.CreateDbContext(); - - return await dbContext.Speakers - .Where(s => keys.Contains(s.Id)) - .ToDictionaryAsync(t => t.Id, cancellationToken); - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/DataLoader/TrackByIdDataLoader.cs b/code/session-7/GraphQL/DataLoader/TrackByIdDataLoader.cs deleted file mode 100644 index 4db1f95..0000000 --- a/code/session-7/GraphQL/DataLoader/TrackByIdDataLoader.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using ConferencePlanner.GraphQL.Data; -using GreenDonut; -using HotChocolate.DataLoader; - -namespace ConferencePlanner.GraphQL.DataLoader -{ - public class TrackByIdDataLoader : BatchDataLoader - { - private readonly IDbContextFactory _dbContextFactory; - - public TrackByIdDataLoader( - IBatchScheduler batchScheduler, - IDbContextFactory dbContextFactory) - : base(batchScheduler) - { - _dbContextFactory = dbContextFactory ?? - throw new ArgumentNullException(nameof(dbContextFactory)); - } - - protected override async Task> LoadBatchAsync( - IReadOnlyList keys, - CancellationToken cancellationToken) - { - await using ApplicationDbContext dbContext = - _dbContextFactory.CreateDbContext(); - - return await dbContext.Tracks - .Where(s => keys.Contains(s.Id)) - .ToDictionaryAsync(t => t.Id, cancellationToken); - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Extensions/ObjectFieldDescriptorExtensions.cs b/code/session-7/GraphQL/Extensions/ObjectFieldDescriptorExtensions.cs index 370c767..49c13a8 100644 --- a/code/session-7/GraphQL/Extensions/ObjectFieldDescriptorExtensions.cs +++ b/code/session-7/GraphQL/Extensions/ObjectFieldDescriptorExtensions.cs @@ -1,32 +1,17 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using HotChocolate.Types; +namespace ConferencePlanner.GraphQL.Extensions; -namespace ConferencePlanner.GraphQL +public static class ObjectFieldDescriptorExtensions { - public static class ObjectFieldDescriptorExtensions + public static IObjectFieldDescriptor UseUpperCase(this IObjectFieldDescriptor descriptor) { - public static IObjectFieldDescriptor UseDbContext( - this IObjectFieldDescriptor descriptor) - where TDbContext : DbContext + return descriptor.Use(next => async context => { - return descriptor.UseScopedService( - create: s => s.GetRequiredService>().CreateDbContext(), - disposeAsync: (s, c) => c.DisposeAsync()); - } + await next(context); - public static IObjectFieldDescriptor UseUpperCase( - this IObjectFieldDescriptor descriptor) - { - return descriptor.Use(next => async context => + if (context.Result is string s) { - await next(context); - - if (context.Result is string s) - { - context.Result = s.ToUpperInvariant(); - } - }); - } + context.Result = s.ToUpperInvariant(); + } + }); } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Extensions/UseApplicationDbContextAttribute.cs b/code/session-7/GraphQL/Extensions/UseApplicationDbContextAttribute.cs deleted file mode 100644 index 79c9907..0000000 --- a/code/session-7/GraphQL/Extensions/UseApplicationDbContextAttribute.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Reflection; -using ConferencePlanner.GraphQL.Data; -using HotChocolate.Types; -using HotChocolate.Types.Descriptors; - -namespace ConferencePlanner.GraphQL -{ - public class UseApplicationDbContextAttribute : ObjectFieldDescriptorAttribute - { - public override void OnConfigure( - IDescriptorContext context, - IObjectFieldDescriptor descriptor, - MemberInfo member) - { - descriptor.UseDbContext(); - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Extensions/UseUpperCaseAttribute.cs b/code/session-7/GraphQL/Extensions/UseUpperCaseAttribute.cs index 8376b5b..b85152d 100644 --- a/code/session-7/GraphQL/Extensions/UseUpperCaseAttribute.cs +++ b/code/session-7/GraphQL/Extensions/UseUpperCaseAttribute.cs @@ -1,17 +1,15 @@ -using HotChocolate.Types; -using HotChocolate.Types.Descriptors; using System.Reflection; +using HotChocolate.Types.Descriptors; + +namespace ConferencePlanner.GraphQL.Extensions; -namespace ConferencePlanner.GraphQL +public sealed class UseUpperCaseAttribute : ObjectFieldDescriptorAttribute { - public class UseUpperCaseAttribute : ObjectFieldDescriptorAttribute + protected override void OnConfigure( + IDescriptorContext context, + IObjectFieldDescriptor descriptor, + MemberInfo member) { - public override void OnConfigure( - IDescriptorContext context, - IObjectFieldDescriptor descriptor, - MemberInfo member) - { - descriptor.UseUpperCase(); - } + descriptor.UseUpperCase(); } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/GraphQL.csproj b/code/session-7/GraphQL/GraphQL.csproj index 0a3a4a0..babfe61 100644 --- a/code/session-7/GraphQL/GraphQL.csproj +++ b/code/session-7/GraphQL/GraphQL.csproj @@ -1,19 +1,24 @@ - - - - net5.0 - ConferencePlanner.GraphQL - enable - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - + + + + net8.0 + enable + enable + ConferencePlanner.GraphQL + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/code/session-7/GraphQL/Migrations/20201010183502_Initial.cs b/code/session-7/GraphQL/Migrations/20201010183502_Initial.cs deleted file mode 100644 index 69e30fb..0000000 --- a/code/session-7/GraphQL/Migrations/20201010183502_Initial.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -namespace GraphQL.Migrations -{ - public partial class Initial : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Speakers", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), - Bio = table.Column(type: "TEXT", maxLength: 4000, nullable: true), - WebSite = table.Column(type: "TEXT", maxLength: 1000, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Speakers", x => x.Id); - }); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Speakers"); - } - } -} diff --git a/code/session-7/GraphQL/Migrations/20201010183502_Initial.Designer.cs b/code/session-7/GraphQL/Migrations/20240807140835_Initial.Designer.cs similarity index 57% rename from code/session-7/GraphQL/Migrations/20201010183502_Initial.Designer.cs rename to code/session-7/GraphQL/Migrations/20240807140835_Initial.Designer.cs index dc170c1..d508064 100644 --- a/code/session-7/GraphQL/Migrations/20201010183502_Initial.Designer.cs +++ b/code/session-7/GraphQL/Migrations/20240807140835_Initial.Designer.cs @@ -4,37 +4,46 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -namespace GraphQL.Migrations +#nullable disable + +namespace ConferencePlanner.GraphQL.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20201010183502_Initial")] + [Migration("20240807140835_Initial")] partial class Initial { + /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "5.0.0"); + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("ConferencePlanner.GraphQL.Data.Speaker", b => { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Bio") .HasMaxLength(4000) - .HasColumnType("TEXT"); + .HasColumnType("character varying(4000)"); b.Property("Name") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); - b.Property("WebSite") + b.Property("Website") .HasMaxLength(1000) - .HasColumnType("TEXT"); + .HasColumnType("character varying(1000)"); b.HasKey("Id"); diff --git a/code/session-7/GraphQL/Migrations/20240807140835_Initial.cs b/code/session-7/GraphQL/Migrations/20240807140835_Initial.cs new file mode 100644 index 0000000..7301c7a --- /dev/null +++ b/code/session-7/GraphQL/Migrations/20240807140835_Initial.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ConferencePlanner.GraphQL.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Speakers", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Bio = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + Website = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Speakers", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Speakers"); + } + } +} diff --git a/code/session-7/GraphQL/Migrations/20201010202211_Refactoring.Designer.cs b/code/session-7/GraphQL/Migrations/20240812080119_Refactoring.Designer.cs similarity index 74% rename from code/session-7/GraphQL/Migrations/20201010202211_Refactoring.Designer.cs rename to code/session-7/GraphQL/Migrations/20240812080119_Refactoring.Designer.cs index 2e3d723..75c788f 100644 --- a/code/session-7/GraphQL/Migrations/20201010202211_Refactoring.Designer.cs +++ b/code/session-7/GraphQL/Migrations/20240812080119_Refactoring.Designer.cs @@ -5,47 +5,56 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -namespace GraphQL.Migrations +#nullable disable + +namespace ConferencePlanner.GraphQL.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20201010202211_Refactoring")] + [Migration("20240812080119_Refactoring")] partial class Refactoring { + /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "5.0.0"); + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("ConferencePlanner.GraphQL.Data.Attendee", b => { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("EmailAddress") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.Property("FirstName") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); b.Property("LastName") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); - b.Property("UserName") + b.Property("Username") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); b.HasKey("Id"); - b.HasIndex("UserName") + b.HasIndex("Username") .IsUnique(); b.ToTable("Attendees"); @@ -55,25 +64,27 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Abstract") .HasMaxLength(4000) - .HasColumnType("TEXT"); + .HasColumnType("character varying(4000)"); b.Property("EndTime") - .HasColumnType("TEXT"); + .HasColumnType("timestamp with time zone"); b.Property("StartTime") - .HasColumnType("TEXT"); + .HasColumnType("timestamp with time zone"); b.Property("Title") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); b.Property("TrackId") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.HasKey("Id"); @@ -85,10 +96,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("ConferencePlanner.GraphQL.Data.SessionAttendee", b => { b.Property("SessionId") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.Property("AttendeeId") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.HasKey("SessionId", "AttendeeId"); @@ -100,10 +111,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("ConferencePlanner.GraphQL.Data.SessionSpeaker", b => { b.Property("SessionId") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.Property("SpeakerId") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.HasKey("SessionId", "SpeakerId"); @@ -116,20 +127,22 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Bio") .HasMaxLength(4000) - .HasColumnType("TEXT"); + .HasColumnType("character varying(4000)"); b.Property("Name") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); - b.Property("WebSite") + b.Property("Website") .HasMaxLength(1000) - .HasColumnType("TEXT"); + .HasColumnType("character varying(1000)"); b.HasKey("Id"); @@ -140,12 +153,14 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Name") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); b.HasKey("Id"); diff --git a/code/session-7/GraphQL/Migrations/20201010202211_Refactoring.cs b/code/session-7/GraphQL/Migrations/20240812080119_Refactoring.cs similarity index 68% rename from code/session-7/GraphQL/Migrations/20201010202211_Refactoring.cs rename to code/session-7/GraphQL/Migrations/20240812080119_Refactoring.cs index ffdcfeb..e544f2e 100644 --- a/code/session-7/GraphQL/Migrations/20201010202211_Refactoring.cs +++ b/code/session-7/GraphQL/Migrations/20240812080119_Refactoring.cs @@ -1,22 +1,27 @@ using System; using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -namespace GraphQL.Migrations +#nullable disable + +namespace ConferencePlanner.GraphQL.Migrations { + /// public partial class Refactoring : Migration { + /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( name: "Attendees", columns: table => new { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - FirstName = table.Column(type: "TEXT", maxLength: 200, nullable: false), - LastName = table.Column(type: "TEXT", maxLength: 200, nullable: false), - UserName = table.Column(type: "TEXT", maxLength: 200, nullable: false), - EmailAddress = table.Column(type: "TEXT", maxLength: 256, nullable: true) + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FirstName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + LastName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Username = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + EmailAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true) }, constraints: table => { @@ -27,9 +32,9 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "Tracks", columns: table => new { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Name = table.Column(type: "TEXT", maxLength: 200, nullable: false) + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false) }, constraints: table => { @@ -40,13 +45,13 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "Sessions", columns: table => new { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), - Abstract = table.Column(type: "TEXT", maxLength: 4000, nullable: true), - StartTime = table.Column(type: "TEXT", nullable: true), - EndTime = table.Column(type: "TEXT", nullable: true), - TrackId = table.Column(type: "INTEGER", nullable: true) + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Abstract = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: true), + StartTime = table.Column(type: "timestamp with time zone", nullable: true), + EndTime = table.Column(type: "timestamp with time zone", nullable: true), + TrackId = table.Column(type: "integer", nullable: true) }, constraints: table => { @@ -55,16 +60,15 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "FK_Sessions_Tracks_TrackId", column: x => x.TrackId, principalTable: "Tracks", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); + principalColumn: "Id"); }); migrationBuilder.CreateTable( name: "SessionAttendee", columns: table => new { - SessionId = table.Column(type: "INTEGER", nullable: false), - AttendeeId = table.Column(type: "INTEGER", nullable: false) + SessionId = table.Column(type: "integer", nullable: false), + AttendeeId = table.Column(type: "integer", nullable: false) }, constraints: table => { @@ -87,8 +91,8 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "SessionSpeaker", columns: table => new { - SessionId = table.Column(type: "INTEGER", nullable: false), - SpeakerId = table.Column(type: "INTEGER", nullable: false) + SessionId = table.Column(type: "integer", nullable: false), + SpeakerId = table.Column(type: "integer", nullable: false) }, constraints: table => { @@ -108,9 +112,9 @@ protected override void Up(MigrationBuilder migrationBuilder) }); migrationBuilder.CreateIndex( - name: "IX_Attendees_UserName", + name: "IX_Attendees_Username", table: "Attendees", - column: "UserName", + column: "Username", unique: true); migrationBuilder.CreateIndex( @@ -129,6 +133,7 @@ protected override void Up(MigrationBuilder migrationBuilder) column: "SpeakerId"); } + /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( diff --git a/code/session-7/GraphQL/Migrations/ApplicationDbContextModelSnapshot.cs b/code/session-7/GraphQL/Migrations/ApplicationDbContextModelSnapshot.cs index a66dfe1..6cc21a5 100644 --- a/code/session-7/GraphQL/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/code/session-7/GraphQL/Migrations/ApplicationDbContextModelSnapshot.cs @@ -4,8 +4,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -namespace GraphQL.Migrations +#nullable disable + +namespace ConferencePlanner.GraphQL.Migrations { [DbContext(typeof(ApplicationDbContext))] partial class ApplicationDbContextModelSnapshot : ModelSnapshot @@ -14,36 +17,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "5.0.0"); + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("ConferencePlanner.GraphQL.Data.Attendee", b => { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("EmailAddress") .HasMaxLength(256) - .HasColumnType("TEXT"); + .HasColumnType("character varying(256)"); b.Property("FirstName") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); b.Property("LastName") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); - b.Property("UserName") + b.Property("Username") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); b.HasKey("Id"); - b.HasIndex("UserName") + b.HasIndex("Username") .IsUnique(); b.ToTable("Attendees"); @@ -53,25 +61,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Abstract") .HasMaxLength(4000) - .HasColumnType("TEXT"); + .HasColumnType("character varying(4000)"); b.Property("EndTime") - .HasColumnType("TEXT"); + .HasColumnType("timestamp with time zone"); b.Property("StartTime") - .HasColumnType("TEXT"); + .HasColumnType("timestamp with time zone"); b.Property("Title") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); b.Property("TrackId") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.HasKey("Id"); @@ -83,10 +93,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("ConferencePlanner.GraphQL.Data.SessionAttendee", b => { b.Property("SessionId") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.Property("AttendeeId") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.HasKey("SessionId", "AttendeeId"); @@ -98,10 +108,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("ConferencePlanner.GraphQL.Data.SessionSpeaker", b => { b.Property("SessionId") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.Property("SpeakerId") - .HasColumnType("INTEGER"); + .HasColumnType("integer"); b.HasKey("SessionId", "SpeakerId"); @@ -114,20 +124,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Bio") .HasMaxLength(4000) - .HasColumnType("TEXT"); + .HasColumnType("character varying(4000)"); b.Property("Name") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); - b.Property("WebSite") + b.Property("Website") .HasMaxLength(1000) - .HasColumnType("TEXT"); + .HasColumnType("character varying(1000)"); b.HasKey("Id"); @@ -138,12 +150,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("Name") .IsRequired() .HasMaxLength(200) - .HasColumnType("TEXT"); + .HasColumnType("character varying(200)"); b.HasKey("Id"); diff --git a/code/session-7/GraphQL/Program.cs b/code/session-7/GraphQL/Program.cs index c4914c6..f2195a7 100644 --- a/code/session-7/GraphQL/Program.cs +++ b/code/session-7/GraphQL/Program.cs @@ -1,26 +1,23 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace ConferencePlanner.GraphQL -{ - public class Program - { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } -} +using ConferencePlanner.GraphQL.Data; +using Microsoft.EntityFrameworkCore; +using StackExchange.Redis; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddDbContext( + options => options.UseNpgsql("Host=127.0.0.1;Username=graphql_workshop;Password=secret")) + .AddGraphQLServer() + .AddGlobalObjectIdentification() + .AddMutationConventions() + .AddFiltering() + .AddSorting() + .AddRedisSubscriptions(_ => ConnectionMultiplexer.Connect("127.0.0.1:6379")) + .AddGraphQLTypes(); + +var app = builder.Build(); + +app.UseWebSockets(); +app.MapGraphQL(); + +await app.RunWithGraphQLCommandsAsync(args); diff --git a/code/session-7/GraphQL/Properties/launchSettings.json b/code/session-7/GraphQL/Properties/launchSettings.json new file mode 100644 index 0000000..c0d9484 --- /dev/null +++ b/code/session-7/GraphQL/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7000;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/code/session-7/GraphQL/Sessions/AddSessionInput.cs b/code/session-7/GraphQL/Sessions/AddSessionInput.cs index db5995f..3474bf3 100644 --- a/code/session-7/GraphQL/Sessions/AddSessionInput.cs +++ b/code/session-7/GraphQL/Sessions/AddSessionInput.cs @@ -1,12 +1,8 @@ -using System.Collections.Generic; using ConferencePlanner.GraphQL.Data; -using HotChocolate.Types.Relay; -namespace ConferencePlanner.GraphQL.Sessions -{ - public record AddSessionInput( - string Title, - string? Abstract, - [ID(nameof(Speaker))] - IReadOnlyList SpeakerIds); -} \ No newline at end of file +namespace ConferencePlanner.GraphQL.Sessions; + +public sealed record AddSessionInput( + string Title, + string? Abstract, + [property: ID] IReadOnlyList SpeakerIds); diff --git a/code/session-7/GraphQL/Sessions/AddSessionPayload.cs b/code/session-7/GraphQL/Sessions/AddSessionPayload.cs deleted file mode 100644 index 82775f8..0000000 --- a/code/session-7/GraphQL/Sessions/AddSessionPayload.cs +++ /dev/null @@ -1,20 +0,0 @@ -using ConferencePlanner.GraphQL.Common; -using ConferencePlanner.GraphQL.Data; - -namespace ConferencePlanner.GraphQL.Sessions -{ - public class AddSessionPayload : Payload - { - public AddSessionPayload(Session session) - { - Session = session; - } - - public AddSessionPayload(UserError error) - : base(new[] { error }) - { - } - - public Session? Session { get; init; } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Sessions/ScheduleSessionInput.cs b/code/session-7/GraphQL/Sessions/ScheduleSessionInput.cs index fc43463..9c4fd11 100644 --- a/code/session-7/GraphQL/Sessions/ScheduleSessionInput.cs +++ b/code/session-7/GraphQL/Sessions/ScheduleSessionInput.cs @@ -1,14 +1,9 @@ -using System; using ConferencePlanner.GraphQL.Data; -using HotChocolate.Types.Relay; -namespace ConferencePlanner.GraphQL.Sessions -{ - public record ScheduleSessionInput( - [ID(nameof(Session))] - int SessionId, - [ID(nameof(Track))] - int TrackId, - DateTimeOffset StartTime, - DateTimeOffset EndTime); -} \ No newline at end of file +namespace ConferencePlanner.GraphQL.Sessions; + +public sealed record ScheduleSessionInput( + [property: ID] int SessionId, + [property: ID] int TrackId, + DateTimeOffset StartTime, + DateTimeOffset EndTime); diff --git a/code/session-7/GraphQL/Sessions/ScheduleSessionPayload.cs b/code/session-7/GraphQL/Sessions/ScheduleSessionPayload.cs deleted file mode 100644 index ce79df5..0000000 --- a/code/session-7/GraphQL/Sessions/ScheduleSessionPayload.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using ConferencePlanner.GraphQL.Common; -using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; - -namespace ConferencePlanner.GraphQL.Sessions -{ - public class ScheduleSessionPayload : SessionPayloadBase - { - public ScheduleSessionPayload(Session session) - : base(session) - { - } - - public ScheduleSessionPayload(UserError error) - : base(new[] { error }) - { - } - - public async Task GetTrackAsync( - TrackByIdDataLoader trackById, - CancellationToken cancellationToken) - { - if (Session is null) - { - return null; - } - - return await trackById.LoadAsync(Session.Id, cancellationToken); - } - - [UseApplicationDbContext] - public async Task?> GetSpeakersAsync( - [ScopedService] ApplicationDbContext dbContext, - SpeakerByIdDataLoader speakerById, - CancellationToken cancellationToken) - { - if (Session is null) - { - return null; - } - - int[] speakerIds = await dbContext.Sessions - .Where(s => s.Id == Session.Id) - .Include(s => s.SessionSpeakers) - .SelectMany(s => s.SessionSpeakers.Select(t => t.SpeakerId)) - .ToArrayAsync(); - - return await speakerById.LoadAsync(speakerIds, cancellationToken); - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Sessions/SessionDataLoaders.cs b/code/session-7/GraphQL/Sessions/SessionDataLoaders.cs new file mode 100644 index 0000000..9eb3732 --- /dev/null +++ b/code/session-7/GraphQL/Sessions/SessionDataLoaders.cs @@ -0,0 +1,18 @@ +using ConferencePlanner.GraphQL.Data; +using Microsoft.EntityFrameworkCore; + +namespace ConferencePlanner.GraphQL.Sessions; + +public static class SessionDataLoaders +{ + [DataLoader] + public static async Task> SessionByIdAsync( + IReadOnlyList ids, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) + { + return await dbContext.Sessions + .Where(s => ids.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, cancellationToken); + } +} diff --git a/code/session-7/GraphQL/Sessions/SessionExceptions.cs b/code/session-7/GraphQL/Sessions/SessionExceptions.cs new file mode 100644 index 0000000..fea5d77 --- /dev/null +++ b/code/session-7/GraphQL/Sessions/SessionExceptions.cs @@ -0,0 +1,9 @@ +namespace ConferencePlanner.GraphQL.Sessions; + +public sealed class EndTimeInvalidException() : Exception("EndTime must be after StartTime."); + +public sealed class NoSpeakerException() : Exception("No speaker assigned."); + +public sealed class SessionNotFoundException() : Exception("Session not found."); + +public sealed class TitleEmptyException() : Exception("The title cannot be empty."); diff --git a/code/session-7/GraphQL/Sessions/SessionFilterInputType.cs b/code/session-7/GraphQL/Sessions/SessionFilterInputType.cs new file mode 100644 index 0000000..b6096b6 --- /dev/null +++ b/code/session-7/GraphQL/Sessions/SessionFilterInputType.cs @@ -0,0 +1,17 @@ +using ConferencePlanner.GraphQL.Data; +using HotChocolate.Data.Filters; + +namespace ConferencePlanner.GraphQL.Sessions; + +public sealed class SessionFilterInputType : FilterInputType +{ + protected override void Configure(IFilterInputTypeDescriptor descriptor) + { + descriptor.BindFieldsExplicitly(); + + descriptor.Field(s => s.Title); + descriptor.Field(s => s.Abstract); + descriptor.Field(s => s.StartTime); + descriptor.Field(s => s.EndTime); + } +} diff --git a/code/session-7/GraphQL/Sessions/SessionMutations.cs b/code/session-7/GraphQL/Sessions/SessionMutations.cs index ac6148f..5986337 100644 --- a/code/session-7/GraphQL/Sessions/SessionMutations.cs +++ b/code/session-7/GraphQL/Sessions/SessionMutations.cs @@ -1,86 +1,80 @@ -using System.Threading; -using System.Threading.Tasks; -using ConferencePlanner.GraphQL.Common; using ConferencePlanner.GraphQL.Data; -using HotChocolate; using HotChocolate.Subscriptions; -using HotChocolate.Types; -namespace ConferencePlanner.GraphQL.Sessions +namespace ConferencePlanner.GraphQL.Sessions; + +[MutationType] +public static class SessionMutations { - [ExtendObjectType(Name = "Mutation")] - public class SessionMutations + [Error] + [Error] + public static async Task AddSessionAsync( + AddSessionInput input, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) { - [UseApplicationDbContext] - public async Task AddSessionAsync( - AddSessionInput input, - [ScopedService] ApplicationDbContext context, - CancellationToken cancellationToken) + if (string.IsNullOrEmpty(input.Title)) { - if (string.IsNullOrEmpty(input.Title)) - { - return new AddSessionPayload( - new UserError("The title cannot be empty.", "TITLE_EMPTY")); - } + throw new TitleEmptyException(); + } - if (input.SpeakerIds.Count == 0) - { - return new AddSessionPayload( - new UserError("No speaker assigned.", "NO_SPEAKER")); - } + if (input.SpeakerIds.Count == 0) + { + throw new NoSpeakerException(); + } - var session = new Session - { - Title = input.Title, - Abstract = input.Abstract, - }; + var session = new Session + { + Title = input.Title, + Abstract = input.Abstract + }; - foreach (int speakerId in input.SpeakerIds) + foreach (var speakerId in input.SpeakerIds) + { + session.SessionSpeakers.Add(new SessionSpeaker { - session.SessionSpeakers.Add(new SessionSpeaker - { - SpeakerId = speakerId - }); - } + SpeakerId = speakerId + }); + } - context.Sessions.Add(session); - await context.SaveChangesAsync(cancellationToken); + dbContext.Sessions.Add(session); - return new AddSessionPayload(session); - } + await dbContext.SaveChangesAsync(cancellationToken); + + return session; + } - [UseApplicationDbContext] - public async Task ScheduleSessionAsync( - ScheduleSessionInput input, - [ScopedService] ApplicationDbContext context, - [Service]ITopicEventSender eventSender) + [Error] + [Error] + public static async Task ScheduleSessionAsync( + ScheduleSessionInput input, + ApplicationDbContext dbContext, + ITopicEventSender eventSender, + CancellationToken cancellationToken) + { + if (input.EndTime < input.StartTime) { - if (input.EndTime < input.StartTime) - { - return new ScheduleSessionPayload( - new UserError("endTime has to be larger than startTime.", "END_TIME_INVALID")); - } + throw new EndTimeInvalidException(); + } - Session session = await context.Sessions.FindAsync(input.SessionId); - int? initialTrackId = session.TrackId; + var session = await dbContext.Sessions.FindAsync([input.SessionId], cancellationToken); - if (session is null) - { - return new ScheduleSessionPayload( - new UserError("Session not found.", "SESSION_NOT_FOUND")); - } + if (session is null) + { + throw new SessionNotFoundException(); + } - session.TrackId = input.TrackId; - session.StartTime = input.StartTime; - session.EndTime = input.EndTime; + session.TrackId = input.TrackId; + session.StartTime = input.StartTime; + session.EndTime = input.EndTime; - await context.SaveChangesAsync(); + await dbContext.SaveChangesAsync(cancellationToken); - await eventSender.SendAsync( - nameof(SessionSubscriptions.OnSessionScheduledAsync), - session.Id); + await eventSender.SendAsync( + nameof(SessionSubscriptions.OnSessionScheduledAsync), + session.Id, + cancellationToken); - return new ScheduleSessionPayload(session); - } + return session; } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Sessions/SessionPayloadBase.cs b/code/session-7/GraphQL/Sessions/SessionPayloadBase.cs deleted file mode 100644 index 888ad50..0000000 --- a/code/session-7/GraphQL/Sessions/SessionPayloadBase.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using ConferencePlanner.GraphQL.Common; -using ConferencePlanner.GraphQL.Data; - -namespace ConferencePlanner.GraphQL.Sessions -{ - public class SessionPayloadBase : Payload - { - protected SessionPayloadBase(Session session) - { - Session = session; - } - - protected SessionPayloadBase(IReadOnlyList errors) - : base(errors) - { - } - - public Session? Session { get; } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Sessions/SessionQueries.cs b/code/session-7/GraphQL/Sessions/SessionQueries.cs index 1cacdfd..60ea5e9 100644 --- a/code/session-7/GraphQL/Sessions/SessionQueries.cs +++ b/code/session-7/GraphQL/Sessions/SessionQueries.cs @@ -1,40 +1,32 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; -using HotChocolate.Types; -using HotChocolate.Types.Relay; -using System.Linq; -using ConferencePlanner.GraphQL.Types; -using HotChocolate.Data; -namespace ConferencePlanner.GraphQL.Sessions +namespace ConferencePlanner.GraphQL.Sessions; + +[QueryType] +public static class SessionQueries { - [ExtendObjectType(Name = "Query")] - public class SessionQueries + [UsePaging] + [UseFiltering] + [UseSorting] + public static IQueryable GetSessions(ApplicationDbContext dbContext) { - [UseApplicationDbContext] - [UsePaging(typeof(NonNullType))] - // TODO: [UseFiltering(typeof(SessionFilterInputType))] - [UseFiltering] - [UseSorting] - public IQueryable GetSessions( - [ScopedService] ApplicationDbContext context) => - context.Sessions; + return dbContext.Sessions.OrderBy(s => s.Title); + } - public Task GetSessionByIdAsync( - [ID(nameof(Session))] int id, - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) => - sessionById.LoadAsync(id, cancellationToken); + [NodeResolver] + public static async Task GetSessionByIdAsync( + int id, + SessionByIdDataLoader sessionById, + CancellationToken cancellationToken) + { + return await sessionById.LoadAsync(id, cancellationToken); + } - public async Task> GetSessionsByIdAsync( - [ID(nameof(Session))] int[] ids, - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) => - await sessionById.LoadAsync(ids, cancellationToken); + public static async Task> GetSessionsByIdAsync( + [ID] int[] ids, + SessionByIdDataLoader sessionById, + CancellationToken cancellationToken) + { + return await sessionById.LoadAsync(ids, cancellationToken); } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Sessions/SessionSubscriptions.cs b/code/session-7/GraphQL/Sessions/SessionSubscriptions.cs index fef037a..17d5323 100644 --- a/code/session-7/GraphQL/Sessions/SessionSubscriptions.cs +++ b/code/session-7/GraphQL/Sessions/SessionSubscriptions.cs @@ -1,21 +1,17 @@ -using System.Threading; -using System.Threading.Tasks; using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; -using HotChocolate.Types; -namespace ConferencePlanner.GraphQL.Sessions +namespace ConferencePlanner.GraphQL.Sessions; + +[SubscriptionType] +public static class SessionSubscriptions { - [ExtendObjectType(Name = "Subscription")] - public class SessionSubscriptions + [Subscribe] + [Topic] + public static async Task OnSessionScheduledAsync( + [EventMessage] int sessionId, + SessionByIdDataLoader sessionById, + CancellationToken cancellationToken) { - [Subscribe] - [Topic] - public Task OnSessionScheduledAsync( - [EventMessage] int sessionId, - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) => - sessionById.LoadAsync(sessionId, cancellationToken); + return await sessionById.LoadAsync(sessionId, cancellationToken); } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Sessions/SessionType.cs b/code/session-7/GraphQL/Sessions/SessionType.cs new file mode 100644 index 0000000..8345ca5 --- /dev/null +++ b/code/session-7/GraphQL/Sessions/SessionType.cs @@ -0,0 +1,65 @@ +using ConferencePlanner.GraphQL.Attendees; +using ConferencePlanner.GraphQL.Data; +using ConferencePlanner.GraphQL.Speakers; +using ConferencePlanner.GraphQL.Tracks; +using Microsoft.EntityFrameworkCore; + +namespace ConferencePlanner.GraphQL.Sessions; + +[ObjectType] +public static partial class SessionType +{ + static partial void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Ignore(s => s.SessionSpeakers) + .Ignore(s => s.SessionAttendees); + + descriptor + .Field(s => s.TrackId) + .ID(); + } + + public static async Task> GetSpeakersAsync( + [Parent] Session session, + ApplicationDbContext dbContext, + SpeakerByIdDataLoader speakerById, + CancellationToken cancellationToken) + { + var speakerIds = await dbContext.Sessions + .Where(s => s.Id == session.Id) + .Include(s => s.SessionSpeakers) + .SelectMany(s => s.SessionSpeakers.Select(ss => ss.SpeakerId)) + .ToArrayAsync(cancellationToken); + + return await speakerById.LoadAsync(speakerIds, cancellationToken); + } + + public static async Task> GetAttendeesAsync( + [Parent] Session session, + ApplicationDbContext dbContext, + AttendeeByIdDataLoader attendeeById, + CancellationToken cancellationToken) + { + var attendeeIds = await dbContext.Sessions + .Where(s => s.Id == session.Id) + .Include(s => s.SessionAttendees) + .SelectMany(s => s.SessionAttendees.Select(sa => sa.AttendeeId)) + .ToArrayAsync(cancellationToken); + + return await attendeeById.LoadAsync(attendeeIds, cancellationToken); + } + + public static async Task GetTrackAsync( + [Parent] Session session, + TrackByIdDataLoader trackById, + CancellationToken cancellationToken) + { + if (session.TrackId is null) + { + return null; + } + + return await trackById.LoadAsync(session.TrackId.Value, cancellationToken); + } +} diff --git a/code/session-7/GraphQL/Speakers/AddSpeakerInput.cs b/code/session-7/GraphQL/Speakers/AddSpeakerInput.cs index a81f45f..bdc584a 100644 --- a/code/session-7/GraphQL/Speakers/AddSpeakerInput.cs +++ b/code/session-7/GraphQL/Speakers/AddSpeakerInput.cs @@ -1,7 +1,6 @@ -namespace ConferencePlanner.GraphQL.Speakers -{ - public record AddSpeakerInput( - string Name, - string? Bio, - string? WebSite); -} \ No newline at end of file +namespace ConferencePlanner.GraphQL.Speakers; + +public sealed record AddSpeakerInput( + string Name, + string? Bio, + string? Website); diff --git a/code/session-7/GraphQL/Speakers/AddSpeakerPayload.cs b/code/session-7/GraphQL/Speakers/AddSpeakerPayload.cs deleted file mode 100644 index aaf0ab0..0000000 --- a/code/session-7/GraphQL/Speakers/AddSpeakerPayload.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using ConferencePlanner.GraphQL.Common; -using ConferencePlanner.GraphQL.Data; - -namespace ConferencePlanner.GraphQL.Speakers -{ - public class AddSpeakerPayload : SpeakerPayloadBase - { - public AddSpeakerPayload(Speaker speaker) - : base(speaker) - { - } - - public AddSpeakerPayload(IReadOnlyList errors) - : base(errors) - { - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Speakers/SpeakerDataLoaders.cs b/code/session-7/GraphQL/Speakers/SpeakerDataLoaders.cs new file mode 100644 index 0000000..9314fe8 --- /dev/null +++ b/code/session-7/GraphQL/Speakers/SpeakerDataLoaders.cs @@ -0,0 +1,18 @@ +using ConferencePlanner.GraphQL.Data; +using Microsoft.EntityFrameworkCore; + +namespace ConferencePlanner.GraphQL.Speakers; + +public static class SpeakerDataLoaders +{ + [DataLoader] + public static async Task> SpeakerByIdAsync( + IReadOnlyList ids, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) + { + return await dbContext.Speakers + .Where(s => ids.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, cancellationToken); + } +} diff --git a/code/session-7/GraphQL/Speakers/SpeakerMutations.cs b/code/session-7/GraphQL/Speakers/SpeakerMutations.cs index 55fc6f5..0a8ad7a 100644 --- a/code/session-7/GraphQL/Speakers/SpeakerMutations.cs +++ b/code/session-7/GraphQL/Speakers/SpeakerMutations.cs @@ -1,29 +1,26 @@ -using System.Threading.Tasks; using ConferencePlanner.GraphQL.Data; -using HotChocolate; -using HotChocolate.Types; -namespace ConferencePlanner.GraphQL.Speakers +namespace ConferencePlanner.GraphQL.Speakers; + +[MutationType] +public static class SpeakerMutations { - [ExtendObjectType(Name = "Mutation")] - public class SpeakerMutations + public static async Task AddSpeakerAsync( + AddSpeakerInput input, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) { - [UseApplicationDbContext] - public async Task AddSpeakerAsync( - AddSpeakerInput input, - [ScopedService] ApplicationDbContext context) + var speaker = new Speaker { - var speaker = new Speaker - { - Name = input.Name, - Bio = input.Bio, - WebSite = input.WebSite - }; + Name = input.Name, + Bio = input.Bio, + Website = input.Website + }; + + dbContext.Speakers.Add(speaker); - context.Speakers.Add(speaker); - await context.SaveChangesAsync(); + await dbContext.SaveChangesAsync(cancellationToken); - return new AddSpeakerPayload(speaker); - } + return speaker; } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Speakers/SpeakerPayloadBase.cs b/code/session-7/GraphQL/Speakers/SpeakerPayloadBase.cs deleted file mode 100644 index a2077d7..0000000 --- a/code/session-7/GraphQL/Speakers/SpeakerPayloadBase.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using ConferencePlanner.GraphQL.Common; -using ConferencePlanner.GraphQL.Data; - -namespace ConferencePlanner.GraphQL.Speakers -{ - public class SpeakerPayloadBase : Payload - { - protected SpeakerPayloadBase(Speaker speaker) - { - Speaker = speaker; - } - - protected SpeakerPayloadBase(IReadOnlyList errors) - : base(errors) - { - } - - public Speaker? Speaker { get; init; } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Speakers/SpeakerQueries.cs b/code/session-7/GraphQL/Speakers/SpeakerQueries.cs index c9b57b8..f90e7d7 100644 --- a/code/session-7/GraphQL/Speakers/SpeakerQueries.cs +++ b/code/session-7/GraphQL/Speakers/SpeakerQueries.cs @@ -1,35 +1,31 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; -using HotChocolate.Types; -using HotChocolate.Types.Relay; -using System.Linq; +using Microsoft.EntityFrameworkCore; -namespace ConferencePlanner.GraphQL.Speakers +namespace ConferencePlanner.GraphQL.Speakers; + +[QueryType] +public static class SpeakerQueries { - [ExtendObjectType(Name = "Query")] - public class SpeakerQueries + [UsePaging] + public static IQueryable GetSpeakers(ApplicationDbContext dbContext) { - [UseApplicationDbContext] - [UsePaging] - public IQueryable GetSpeakers( - [ScopedService] ApplicationDbContext context) => - context.Speakers.OrderBy(t => t.Name); + return dbContext.Speakers.OrderBy(s => s.Name); + } - public Task GetSpeakerByIdAsync( - [ID(nameof(Speaker))]int id, - SpeakerByIdDataLoader dataLoader, - CancellationToken cancellationToken) => - dataLoader.LoadAsync(id, cancellationToken); + [NodeResolver] + public static async Task GetSpeakerByIdAsync( + int id, + SpeakerByIdDataLoader speakerById, + CancellationToken cancellationToken) + { + return await speakerById.LoadAsync(id, cancellationToken); + } - public async Task> GetSpeakersByIdAsync( - [ID(nameof(Speaker))]int[] ids, - SpeakerByIdDataLoader dataLoader, - CancellationToken cancellationToken) => - await dataLoader.LoadAsync(ids, cancellationToken); + public static async Task> GetSpeakersByIdAsync( + [ID] int[] ids, + SpeakerByIdDataLoader speakerById, + CancellationToken cancellationToken) + { + return await speakerById.LoadAsync(ids, cancellationToken); } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Speakers/SpeakerType.cs b/code/session-7/GraphQL/Speakers/SpeakerType.cs new file mode 100644 index 0000000..52eff0a --- /dev/null +++ b/code/session-7/GraphQL/Speakers/SpeakerType.cs @@ -0,0 +1,29 @@ +using ConferencePlanner.GraphQL.Data; +using ConferencePlanner.GraphQL.Sessions; +using Microsoft.EntityFrameworkCore; + +namespace ConferencePlanner.GraphQL.Speakers; + +[ObjectType] +public static partial class SpeakerType +{ + static partial void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Ignore(s => s.SessionSpeakers); + } + + public static async Task> GetSessionsAsync( + [Parent] Speaker speaker, + ApplicationDbContext dbContext, + SessionByIdDataLoader sessionById, + CancellationToken cancellationToken) + { + var sessionIds = await dbContext.Speakers + .Where(s => s.Id == speaker.Id) + .Include(s => s.SessionSpeakers) + .SelectMany(s => s.SessionSpeakers.Select(ss => ss.SessionId)) + .ToArrayAsync(cancellationToken); + + return await sessionById.LoadAsync(sessionIds, cancellationToken); + } +} diff --git a/code/session-7/GraphQL/Startup.cs b/code/session-7/GraphQL/Startup.cs deleted file mode 100644 index efcc20a..0000000 --- a/code/session-7/GraphQL/Startup.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using ConferencePlanner.GraphQL; -using ConferencePlanner.GraphQL.Attendees; -using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using ConferencePlanner.GraphQL.Sessions; -using ConferencePlanner.GraphQL.Speakers; -using ConferencePlanner.GraphQL.Tracks; -using ConferencePlanner.GraphQL.Types; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace ConferencePlanner.GraphQL -{ - public class Startup - { - // This method gets called by the runtime. Use this method to add services to the container. - // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 - public void ConfigureServices(IServiceCollection services) - { - services.AddPooledDbContextFactory( - options => options.UseSqlite("Data Source=conferences.db")); - - services - .AddGraphQLServer() - .AddQueryType(d => d.Name("Query")) - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddMutationType(d => d.Name("Mutation")) - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddSubscriptionType(d => d.Name("Subscription")) - .AddTypeExtension() - .AddTypeExtension() - .AddType() - .AddType() - .AddType() - .AddType() - .EnableRelaySupport() - .AddFiltering() - .AddSorting() - .AddInMemorySubscriptions() - .AddDataLoader() - .AddDataLoader(); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseWebSockets(); - app.UseRouting(); - - app.UseEndpoints(endpoints => - { - endpoints.MapGraphQL(); - }); - } - } -} diff --git a/code/session-7/GraphQL/Tracks/AddTrackInput.cs b/code/session-7/GraphQL/Tracks/AddTrackInput.cs index 5c83b34..1aaf313 100644 --- a/code/session-7/GraphQL/Tracks/AddTrackInput.cs +++ b/code/session-7/GraphQL/Tracks/AddTrackInput.cs @@ -1,4 +1,3 @@ -namespace ConferencePlanner.GraphQL.Tracks -{ - public record AddTrackInput(string Name); -} \ No newline at end of file +namespace ConferencePlanner.GraphQL.Tracks; + +public sealed record AddTrackInput(string Name); diff --git a/code/session-7/GraphQL/Tracks/AddTrackPayload.cs b/code/session-7/GraphQL/Tracks/AddTrackPayload.cs deleted file mode 100644 index 8f35b13..0000000 --- a/code/session-7/GraphQL/Tracks/AddTrackPayload.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using ConferencePlanner.GraphQL.Common; -using ConferencePlanner.GraphQL.Data; - -namespace ConferencePlanner.GraphQL.Tracks -{ - public class AddTrackPayload : TrackPayloadBase - { - public AddTrackPayload(Track track) - : base(track) - { - } - - public AddTrackPayload(IReadOnlyList errors) - : base(errors) - { - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Tracks/RenameTrackInput.cs b/code/session-7/GraphQL/Tracks/RenameTrackInput.cs index 516c6a0..d11ad39 100644 --- a/code/session-7/GraphQL/Tracks/RenameTrackInput.cs +++ b/code/session-7/GraphQL/Tracks/RenameTrackInput.cs @@ -1,7 +1,5 @@ using ConferencePlanner.GraphQL.Data; -using HotChocolate.Types.Relay; -namespace ConferencePlanner.GraphQL.Tracks -{ - public record RenameTrackInput([ID(nameof(Track))] int Id, string Name); -} \ No newline at end of file +namespace ConferencePlanner.GraphQL.Tracks; + +public sealed record RenameTrackInput([property: ID] int Id, string Name); diff --git a/code/session-7/GraphQL/Tracks/RenameTrackPayload.cs b/code/session-7/GraphQL/Tracks/RenameTrackPayload.cs deleted file mode 100644 index ca4c8a1..0000000 --- a/code/session-7/GraphQL/Tracks/RenameTrackPayload.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using ConferencePlanner.GraphQL.Common; -using ConferencePlanner.GraphQL.Data; - -namespace ConferencePlanner.GraphQL.Tracks -{ - public class RenameTrackPayload : TrackPayloadBase - { - public RenameTrackPayload(Track track) - : base(track) - { - } - - public RenameTrackPayload(IReadOnlyList errors) - : base(errors) - { - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Tracks/TrackDataLoaders.cs b/code/session-7/GraphQL/Tracks/TrackDataLoaders.cs new file mode 100644 index 0000000..25ff6a4 --- /dev/null +++ b/code/session-7/GraphQL/Tracks/TrackDataLoaders.cs @@ -0,0 +1,18 @@ +using ConferencePlanner.GraphQL.Data; +using Microsoft.EntityFrameworkCore; + +namespace ConferencePlanner.GraphQL.Tracks; + +public static class TrackDataLoaders +{ + [DataLoader] + public static async Task> TrackByIdAsync( + IReadOnlyList ids, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) + { + return await dbContext.Tracks + .Where(t => ids.Contains(t.Id)) + .ToDictionaryAsync(t => t.Id, cancellationToken); + } +} diff --git a/code/session-7/GraphQL/Tracks/TrackExceptions.cs b/code/session-7/GraphQL/Tracks/TrackExceptions.cs new file mode 100644 index 0000000..8df488d --- /dev/null +++ b/code/session-7/GraphQL/Tracks/TrackExceptions.cs @@ -0,0 +1,3 @@ +namespace ConferencePlanner.GraphQL.Tracks; + +public sealed class TrackNotFoundException() : Exception("Track not found."); diff --git a/code/session-7/GraphQL/Tracks/TrackMutations.cs b/code/session-7/GraphQL/Tracks/TrackMutations.cs index 88727b9..a91671c 100644 --- a/code/session-7/GraphQL/Tracks/TrackMutations.cs +++ b/code/session-7/GraphQL/Tracks/TrackMutations.cs @@ -1,40 +1,41 @@ -using System.Threading; -using System.Threading.Tasks; using ConferencePlanner.GraphQL.Data; -using HotChocolate; -using HotChocolate.Types; -namespace ConferencePlanner.GraphQL.Tracks +namespace ConferencePlanner.GraphQL.Tracks; + +[MutationType] +public static class TrackMutations { - [ExtendObjectType(Name = "Mutation")] - public class TrackMutations + public static async Task AddTrackAsync( + AddTrackInput input, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) { - [UseApplicationDbContext] - public async Task AddTrackAsync( - AddTrackInput input, - [ScopedService] ApplicationDbContext context, - CancellationToken cancellationToken) - { - var track = new Track { Name = input.Name }; - context.Tracks.Add(track); + var track = new Track { Name = input.Name }; - await context.SaveChangesAsync(cancellationToken); + dbContext.Tracks.Add(track); - return new AddTrackPayload(track); - } + await dbContext.SaveChangesAsync(cancellationToken); - [UseApplicationDbContext] - public async Task RenameTrackAsync( - RenameTrackInput input, - [ScopedService] ApplicationDbContext context, - CancellationToken cancellationToken) - { - Track track = await context.Tracks.FindAsync(input.Id); - track.Name = input.Name; + return track; + } - await context.SaveChangesAsync(cancellationToken); + [Error] + public static async Task RenameTrackAsync( + RenameTrackInput input, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) + { + var track = await dbContext.Tracks.FindAsync([input.Id], cancellationToken); - return new RenameTrackPayload(track); + if (track is null) + { + throw new TrackNotFoundException(); } + + track.Name = input.Name; + + await dbContext.SaveChangesAsync(cancellationToken); + + return track; } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Tracks/TrackPayloadBase.cs b/code/session-7/GraphQL/Tracks/TrackPayloadBase.cs deleted file mode 100644 index de11da7..0000000 --- a/code/session-7/GraphQL/Tracks/TrackPayloadBase.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using ConferencePlanner.GraphQL.Common; -using ConferencePlanner.GraphQL.Data; - -namespace ConferencePlanner.GraphQL.Tracks -{ - public class TrackPayloadBase : Payload - { - public TrackPayloadBase(Track track) - { - Track = track; - } - - public TrackPayloadBase(IReadOnlyList errors) - : base(errors) - { - } - - public Track? Track { get; } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Tracks/TrackQueries.cs b/code/session-7/GraphQL/Tracks/TrackQueries.cs index 7ced249..ce9227d 100644 --- a/code/session-7/GraphQL/Tracks/TrackQueries.cs +++ b/code/session-7/GraphQL/Tracks/TrackQueries.cs @@ -1,49 +1,49 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; -using HotChocolate.Types; -using HotChocolate.Types.Relay; +using Microsoft.EntityFrameworkCore; + +namespace ConferencePlanner.GraphQL.Tracks; -namespace ConferencePlanner.GraphQL.Tracks +[QueryType] +public static class TrackQueries { - [ExtendObjectType(Name = "Query")] - public class TrackQueries + [UsePaging] + public static IQueryable GetTracks(ApplicationDbContext dbContext) { - [UseApplicationDbContext] - [UsePaging] - public IQueryable GetTracks( - [ScopedService] ApplicationDbContext context) => - context.Tracks.OrderBy(t => t.Name); + return dbContext.Tracks.OrderBy(t => t.Name); + } - [UseApplicationDbContext] - public Task GetTrackByNameAsync( - string name, - [ScopedService] ApplicationDbContext context, - CancellationToken cancellationToken) => - context.Tracks.FirstAsync(t => t.Name == name); + [NodeResolver] + public static async Task GetTrackByIdAsync( + int id, + TrackByIdDataLoader trackById, + CancellationToken cancellationToken) + { + return await trackById.LoadAsync(id, cancellationToken); + } - [UseApplicationDbContext] - public async Task> GetTrackByNamesAsync( - string[] names, - [ScopedService] ApplicationDbContext context, - CancellationToken cancellationToken) => - await context.Tracks.Where(t => names.Contains(t.Name)).ToListAsync(); + public static async Task> GetTracksByIdAsync( + [ID] int[] ids, + TrackByIdDataLoader trackById, + CancellationToken cancellationToken) + { + return await trackById.LoadAsync(ids, cancellationToken); + } - public Task GetTrackByIdAsync( - [ID(nameof(Track))] int id, - TrackByIdDataLoader trackById, - CancellationToken cancellationToken) => - trackById.LoadAsync(id, cancellationToken); + public static async Task GetTrackByNameAsync( + string name, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) + { + return await dbContext.Tracks.FirstAsync(t => t.Name == name, cancellationToken); + } - public async Task> GetTracksByIdAsync( - [ID(nameof(Track))] int[] ids, - TrackByIdDataLoader trackById, - CancellationToken cancellationToken) => - await trackById.LoadAsync(ids, cancellationToken); + public static async Task> GetTracksByNameAsync( + string[] names, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) + { + return await dbContext.Tracks + .Where(t => names.Contains(t.Name)) + .ToListAsync(cancellationToken); } -} \ No newline at end of file +} diff --git a/code/session-7/GraphQL/Tracks/TrackType.cs b/code/session-7/GraphQL/Tracks/TrackType.cs new file mode 100644 index 0000000..5b66a5b --- /dev/null +++ b/code/session-7/GraphQL/Tracks/TrackType.cs @@ -0,0 +1,32 @@ +using ConferencePlanner.GraphQL.Data; +using ConferencePlanner.GraphQL.Extensions; +using ConferencePlanner.GraphQL.Sessions; +using Microsoft.EntityFrameworkCore; + +namespace ConferencePlanner.GraphQL.Tracks; + +[ObjectType] +public static partial class TrackType +{ + static partial void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Field(t => t.Name) + .UseUpperCase(); + } + + [UsePaging] + public static async Task> GetSessionsAsync( + [Parent] Track track, + ApplicationDbContext dbContext, + SessionByIdDataLoader sessionById, + CancellationToken cancellationToken) + { + var sessionIds = await dbContext.Sessions + .Where(s => s.TrackId == track.Id) + .Select(s => s.Id) + .ToArrayAsync(cancellationToken); + + return await sessionById.LoadAsync(sessionIds, cancellationToken); + } +} diff --git a/code/session-7/GraphQL/Types/AttendeeType.cs b/code/session-7/GraphQL/Types/AttendeeType.cs deleted file mode 100644 index 62274c7..0000000 --- a/code/session-7/GraphQL/Types/AttendeeType.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; -using HotChocolate.Resolvers; -using HotChocolate.Types; - -namespace ConferencePlanner.GraphQL.Types -{ - public class AttendeeType : ObjectType - { - protected override void Configure(IObjectTypeDescriptor descriptor) - { - descriptor - .ImplementsNode() - .IdField(t => t.Id) - .ResolveNode((ctx, id) => ctx.DataLoader().LoadAsync(id, ctx.RequestAborted)); - - descriptor - .Field(t => t.SessionsAttendees) - .ResolveWith(t => t.GetSessionsAsync(default!, default!, default!, default)) - .UseDbContext() - .Name("sessions"); - } - - private class AttendeeResolvers - { - public async Task> GetSessionsAsync( - Attendee attendee, - [ScopedService] ApplicationDbContext dbContext, - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) - { - int[] speakerIds = await dbContext.Attendees - .Where(a => a.Id == attendee.Id) - .Include(a => a.SessionsAttendees) - .SelectMany(a => a.SessionsAttendees.Select(t => t.SessionId)) - .ToArrayAsync(); - - return await sessionById.LoadAsync(speakerIds, cancellationToken); - } - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Types/SessionFilterInputType.cs b/code/session-7/GraphQL/Types/SessionFilterInputType.cs deleted file mode 100644 index 9a514e8..0000000 --- a/code/session-7/GraphQL/Types/SessionFilterInputType.cs +++ /dev/null @@ -1,14 +0,0 @@ -using ConferencePlanner.GraphQL.Data; -using HotChocolate.Data.Filters; - -namespace ConferencePlanner.GraphQL.Types -{ - public class SessionFilterInputType : FilterInputType - { - protected override void Configure(IFilterInputTypeDescriptor descriptor) - { - descriptor.Ignore(t => t.Id); - descriptor.Ignore(t => t.TrackId); - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Types/SessionType.cs b/code/session-7/GraphQL/Types/SessionType.cs deleted file mode 100644 index 8a558f7..0000000 --- a/code/session-7/GraphQL/Types/SessionType.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; -using HotChocolate.Resolvers; -using HotChocolate.Types; -using HotChocolate.Types.Relay; - -namespace ConferencePlanner.GraphQL.Types -{ - public class SessionType : ObjectType - { - protected override void Configure(IObjectTypeDescriptor descriptor) - { - descriptor - .ImplementsNode() - .IdField(t => t.Id) - .ResolveNode((ctx, id) => ctx.DataLoader().LoadAsync(id, ctx.RequestAborted)); - - descriptor - .Field(t => t.SessionSpeakers) - .ResolveWith(t => t.GetSpeakersAsync(default!, default!, default!, default)) - .UseDbContext() - .Name("speakers"); - - descriptor - .Field(t => t.SessionAttendees) - .ResolveWith(t => t.GetAttendeesAsync(default!, default!, default!, default)) - .UseDbContext() - .Name("attendees"); - - descriptor - .Field(t => t.Track) - .ResolveWith(t => t.GetTrackAsync(default!, default!, default)); - - descriptor - .Field(t => t.TrackId) - .ID(nameof(Track)); - } - - private class SessionResolvers - { - public async Task> GetSpeakersAsync( - Session session, - [ScopedService] ApplicationDbContext dbContext, - SpeakerByIdDataLoader speakerById, - CancellationToken cancellationToken) - { - int[] speakerIds = await dbContext.Sessions - .Where(s => s.Id == session.Id) - .Include(s => s.SessionSpeakers) - .SelectMany(s => s.SessionSpeakers.Select(t => t.SpeakerId)) - .ToArrayAsync(); - - return await speakerById.LoadAsync(speakerIds, cancellationToken); - } - - public async Task> GetAttendeesAsync( - Session session, - [ScopedService] ApplicationDbContext dbContext, - AttendeeByIdDataLoader attendeeById, - CancellationToken cancellationToken) - { - int[] attendeeIds = await dbContext.Sessions - .Where(s => s.Id == session.Id) - .Include(session => session.SessionAttendees) - .SelectMany(session => session.SessionAttendees.Select(t => t.AttendeeId)) - .ToArrayAsync(); - - return await attendeeById.LoadAsync(attendeeIds, cancellationToken); - } - - public async Task GetTrackAsync( - Session session, - TrackByIdDataLoader trackById, - CancellationToken cancellationToken) - { - if (session.TrackId is null) - { - return null; - } - - return await trackById.LoadAsync(session.TrackId.Value, cancellationToken); - } - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Types/SpeakerType.cs b/code/session-7/GraphQL/Types/SpeakerType.cs deleted file mode 100644 index 89837f0..0000000 --- a/code/session-7/GraphQL/Types/SpeakerType.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; -using HotChocolate.Resolvers; -using HotChocolate.Types; - -namespace ConferencePlanner.GraphQL.Types -{ - public class SpeakerType : ObjectType - { - protected override void Configure(IObjectTypeDescriptor descriptor) - { - descriptor - .ImplementsNode() - .IdField(t => t.Id) - .ResolveNode((ctx, id) => ctx.DataLoader().LoadAsync(id, ctx.RequestAborted)); - - descriptor - .Field(t => t.SessionSpeakers) - .ResolveWith(t => t.GetSessionsAsync(default!, default!, default!, default)) - .UseDbContext() - .Name("sessions"); - } - - private class SpeakerResolvers - { - public async Task> GetSessionsAsync( - Speaker speaker, - [ScopedService] ApplicationDbContext dbContext, - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) - { - int[] speakerIds = await dbContext.Speakers - .Where(s => s.Id == speaker.Id) - .Include(s => s.SessionSpeakers) - .SelectMany(s => s.SessionSpeakers.Select(t => t.SessionId)) - .ToArrayAsync(); - - return await sessionById.LoadAsync(speakerIds, cancellationToken); - } - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/Types/TrackType.cs b/code/session-7/GraphQL/Types/TrackType.cs deleted file mode 100644 index dfa4faf..0000000 --- a/code/session-7/GraphQL/Types/TrackType.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using ConferencePlanner.GraphQL.Data; -using ConferencePlanner.GraphQL.DataLoader; -using HotChocolate; -using HotChocolate.Resolvers; -using HotChocolate.Types; - -namespace ConferencePlanner.GraphQL.Types -{ - public class TrackType : ObjectType - { - protected override void Configure(IObjectTypeDescriptor descriptor) - { - descriptor - .ImplementsNode() - .IdField(t => t.Id) - .ResolveNode((ctx, id) => - ctx.DataLoader().LoadAsync(id, ctx.RequestAborted)); - - descriptor - .Field(t => t.Sessions) - .ResolveWith(t => t.GetSessionsAsync(default!, default!, default!, default)) - .UseDbContext() - .UsePaging>() - .Name("sessions"); - - descriptor - .Field(t => t.Name) - .UseUpperCase(); - } - - private class TrackResolvers - { - public async Task> GetSessionsAsync( - Track track, - [ScopedService] ApplicationDbContext dbContext, - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) - { - int[] sessionIds = await dbContext.Sessions - .Where(s => s.Id == track.Id) - .Select(s => s.Id) - .ToArrayAsync(); - - return await sessionById.LoadAsync(sessionIds, cancellationToken); - } - } - } -} \ No newline at end of file diff --git a/code/session-7/GraphQL/appsettings.Development.json b/code/session-7/GraphQL/appsettings.Development.json index dba68eb..0c208ae 100644 --- a/code/session-7/GraphQL/appsettings.Development.json +++ b/code/session-7/GraphQL/appsettings.Development.json @@ -1,9 +1,8 @@ { "Logging": { "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" + "Default": "Information", + "Microsoft.AspNetCore": "Warning" } } } diff --git a/code/session-7/GraphQL/appsettings.json b/code/session-7/GraphQL/appsettings.json index 81ff877..10f68b8 100644 --- a/code/session-7/GraphQL/appsettings.json +++ b/code/session-7/GraphQL/appsettings.json @@ -1,10 +1,9 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "AllowedHosts": "*" -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/code/session-7/docker-compose.yml b/code/session-7/docker-compose.yml new file mode 100644 index 0000000..a9037de --- /dev/null +++ b/code/session-7/docker-compose.yml @@ -0,0 +1,33 @@ +name: graphql-workshop + +services: + graphql-workshop-postgres: + container_name: graphql-workshop-postgres + image: postgres:16.3 + environment: + POSTGRES_USER: graphql_workshop + POSTGRES_PASSWORD: secret + POSTGRES_DB: graphql_workshop + networks: [graphql-workshop] + ports: ["5432:5432"] + volumes: + - type: volume + source: postgres-data + target: /var/lib/postgresql/data + graphql-workshop-redis: + container_name: graphql-workshop-redis + image: redis:7.4 + networks: [graphql-workshop] + ports: ["6379:6379"] + volumes: + - type: volume + source: redis-data + target: /data + +networks: + graphql-workshop: + name: graphql-workshop + +volumes: + postgres-data: + redis-data: diff --git a/docs/7-subscriptions.md b/docs/7-subscriptions.md index 5f317da..f50b665 100644 --- a/docs/7-subscriptions.md +++ b/docs/7-subscriptions.md @@ -1,862 +1,575 @@ -- [Adding real-time functionality with subscriptions](#adding-real-time-functionality-with-subscriptions) - - [Refactor GraphQL API](#refactor-graphql-api) - - [Add `registerAttendee` Mutation](#add-registerattendee-mutation) - - [Add `checkInAttendee` Mutation](#add-checkinattendee-mutation) - - [Add `onSessionScheduled` Subscription](#add-onsessionscheduled-subscription) - - [Add `onAttendeeCheckedIn` subscription](#add-onattendeecheckedin-subscription) - - [Summary](#summary) - # Adding real-time functionality with subscriptions -For the last few parts of our journey through GraphQL, we have dealt with queries and mutations. In many APIs, this is all people need or want, but GraphQL also offers us real-time capabilities where we can formulate what data we want to receive when a specific event happens. - -For our conference API, we would like to introduce two events a user can subscribe to. So, whenever a session is scheduled, we want to be notified. An `onSessionScheduled` event would allow us to send the user notifications whenever a new session is available or whenever a schedule for a specific session has changed. - -The second case that we have for subscriptions is whenever a user checks in to a session we want to raise a subscription so that we can notify users that the space in a session is running low or even have some analytics tool subscribe to this event. - -## Refactor GraphQL API - -Before we can start with introducing our new subscriptions, we need first to bring in some new types and add some more packages. - -1. Add a new directory `Attendees`. - - ```console - mkdir GraphQL/Attendees - ``` +- [Adding real-time functionality with subscriptions](#adding-real-time-functionality-with-subscriptions) + - [Adding to our GraphQL API](#adding-to-our-graphql-api) + - [Adding a `registerAttendee` mutation](#adding-a-registerattendee-mutation) + - [Adding a `checkInAttendee` mutation](#adding-a-checkinattendee-mutation) + - [Adding an `onSessionScheduled` subscription](#adding-an-onsessionscheduled-subscription) + - [Adding an `onAttendeeCheckedIn` subscription](#adding-an-onattendeecheckedin-subscription) + - [Summary](#summary) -1. Create a new class `AttendeeQueries` located in the `Attendees` directory with the following content: +For the last few parts of our journey through GraphQL, we've dealt with queries and mutations. In many APIs, this is all that people need or want, but GraphQL also offers us real-time capabilities where we can formulate what data we want to receive when a specific event occurs. - ```csharp - using System.Collections.Generic; - using System.Threading; - using System.Threading.Tasks; - using ConferencePlanner.GraphQL.Data; - using ConferencePlanner.GraphQL.DataLoader; - using HotChocolate; - using HotChocolate.Types; - using HotChocolate.Types.Relay; - using System.Linq; +For our conference API, we'd like to introduce two events that a user can subscribe to. Firstly, whenever a session is scheduled, we want to be notified. An `onSessionScheduled` event would allow us to send the user notifications whenever a new session is available, or whenever a schedule for a specific session has changed. - namespace ConferencePlanner.GraphQL.Attendees - { - [ExtendObjectType(Name = "Query")] - public class AttendeeQueries - { - [UseApplicationDbContext] - [UsePaging] - public IQueryable GetAttendees( - [ScopedService] ApplicationDbContext context) => - context.Attendees; +The second case that we have for subscriptions is whenever a user checks in to a session, we want to raise a subscription so that we can notify users that the space in a session is running low or even have some analytics tool subscribe to this event. - public Task GetAttendeeByIdAsync( - [ID(nameof(Attendee))]int id, - AttendeeByIdDataLoader attendeeById, - CancellationToken cancellationToken) => - attendeeById.LoadAsync(id, cancellationToken); +## Adding to our GraphQL API - public async Task> GetAttendeesByIdAsync( - [ID(nameof(Attendee))]int[] ids, - AttendeeByIdDataLoader attendeeById, - CancellationToken cancellationToken) => - await attendeeById.LoadAsync(ids, cancellationToken); - } - } - ``` +Before we can start with introducing our new subscriptions, we need to first bring in some new types. -1. Add a class `AttendeePayloadBase` to the `Attendees` directory. +1. Create a new class named `AttendeeQueries`, in the `Attendees` directory, with the following content: ```csharp - using System.Collections.Generic; - using ConferencePlanner.GraphQL.Common; using ConferencePlanner.GraphQL.Data; - namespace ConferencePlanner.GraphQL.Attendees + namespace ConferencePlanner.GraphQL.Attendees; + + [QueryType] + public static class AttendeeQueries { - public class AttendeePayloadBase : Payload + [UsePaging] + public static IQueryable GetAttendees(ApplicationDbContext dbContext) { - protected AttendeePayloadBase(Attendee attendee) - { - Attendee = attendee; - } + return dbContext.Attendees.OrderBy(a => a.Username); + } - protected AttendeePayloadBase(IReadOnlyList errors) - : base(errors) - { - } + [NodeResolver] + public static async Task GetAttendeeByIdAsync( + int id, + AttendeeByIdDataLoader attendeeById, + CancellationToken cancellationToken) + { + return await attendeeById.LoadAsync(id, cancellationToken); + } - public Attendee? Attendee { get; } + public static async Task> GetAttendeesByIdAsync( + [ID] int[] ids, + AttendeeByIdDataLoader attendeeById, + CancellationToken cancellationToken) + { + return await attendeeById.LoadAsync(ids, cancellationToken); } } ``` -### Add `registerAttendee` Mutation +### Adding a `registerAttendee` mutation -We now have the base types integrated and can start adding the attendee mutations. We will begin by adding in the `registerAttendee` Mutation. +We now have the base types integrated and can start adding the attendee mutations. We'll begin by adding in the `registerAttendee` mutation. -1. Add a new class `RegisterAttendeeInput` to the `Attendees` directory. +1. Add a new class named `RegisterAttendeeInput` to the `Attendees` directory: ```csharp - namespace ConferencePlanner.GraphQL.Attendees - { - public record RegisterAttendeeInput( - string FirstName, - string LastName, - string UserName, - string EmailAddress); - } + namespace ConferencePlanner.GraphQL.Attendees; + + public sealed record RegisterAttendeeInput( + string FirstName, + string LastName, + string Username, + string EmailAddress); ``` -1. Now, add the `RegisterAttendeePayload` class to the `Attendees` directory. +1. Add an `AttendeeMutations` class with a `RegisterAttendeeAsync` resolver to the `Attendees` directory: ```csharp - using ConferencePlanner.GraphQL.Common; using ConferencePlanner.GraphQL.Data; - namespace ConferencePlanner.GraphQL.Attendees + namespace ConferencePlanner.GraphQL.Attendees; + + [MutationType] + public static class AttendeeMutations { - public class RegisterAttendeePayload : AttendeePayloadBase + public static async Task RegisterAttendeeAsync( + RegisterAttendeeInput input, + ApplicationDbContext dbContext, + CancellationToken cancellationToken) { - public RegisterAttendeePayload(Attendee attendee) - : base(attendee) + var attendee = new Attendee { - } + FirstName = input.FirstName, + LastName = input.LastName, + Username = input.Username, + EmailAddress = input.EmailAddress + }; - public RegisterAttendeePayload(UserError error) - : base(new[] { error }) - { - } + dbContext.Attendees.Add(attendee); + + await dbContext.SaveChangesAsync(cancellationToken); + + return attendee; } } ``` -1. Add the `AttendeeMutations` with the `RegisterAttendeeAsync` resolver to the `Attendees` directory. - - ```csharp - using System.Threading; - using System.Threading.Tasks; - using ConferencePlanner.GraphQL.Common; - using ConferencePlanner.GraphQL.Data; - using HotChocolate; - using HotChocolate.Types; - using HotChocolate.Subscriptions; - - namespace ConferencePlanner.GraphQL.Attendees - { - [ExtendObjectType(Name = "Mutation")] - public class AttendeeMutations - { - [UseApplicationDbContext] - public async Task RegisterAttendeeAsync( - RegisterAttendeeInput input, - [ScopedService] ApplicationDbContext context, - CancellationToken cancellationToken) - { - var attendee = new Attendee - { - FirstName = input.FirstName, - LastName = input.LastName, - UserName = input.UserName, - EmailAddress = input.EmailAddress - }; - - context.Attendees.Add(attendee); - - await context.SaveChangesAsync(cancellationToken); - - return new RegisterAttendeePayload(attendee); - } - } - } - ``` - -### Add `checkInAttendee` Mutation - -Now that we have the mutation in to register new attendees, let us move on to add another mutation that will allow us to check-in a user to a session. - -1. Add the `CheckInAttendeeInput` to the `Attendees` directory. +### Adding a `checkInAttendee` mutation - ```csharp - using ConferencePlanner.GraphQL.Data; - using HotChocolate.Types.Relay; - - namespace ConferencePlanner.GraphQL.Attendees - { - public record CheckInAttendeeInput( - [ID(nameof(Session))] - int SessionId, - [ID(nameof(Attendee))] - int AttendeeId); - } - ``` +Now that we have the mutation in to register new attendees, let's move on to adding another mutation that will allow us to check a user into a session. -1. Next we add the payload type for the `CheckInAttendeePayload` Mutation: +1. Add a `CheckInAttendeeInput` record to the `Attendees` directory: ```csharp - using System.Threading; - using System.Threading.Tasks; - using ConferencePlanner.GraphQL.Common; using ConferencePlanner.GraphQL.Data; - using ConferencePlanner.GraphQL.DataLoader; - namespace ConferencePlanner.GraphQL.Attendees - { - public class CheckInAttendeePayload : AttendeePayloadBase - { - private int? _sessionId; + namespace ConferencePlanner.GraphQL.Attendees; - public CheckInAttendeePayload(Attendee attendee, int sessionId) - : base(attendee) - { - _sessionId = sessionId; - } + public sealed record CheckInAttendeeInput( + [property: ID] int SessionId, + [property: ID] int AttendeeId); + ``` - public CheckInAttendeePayload(UserError error) - : base(new[] { error }) - { - } +1. Add an `AttendeeExceptions.cs` file to the `Attendees` directory, with the following code: - public async Task GetSessionAsync( - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) - { - if (_sessionId.HasValue) - { - return await sessionById.LoadAsync(_sessionId.Value, cancellationToken); - } + ```csharp + namespace ConferencePlanner.GraphQL.Attendees; - return null; - } - } - } + public sealed class AttendeeNotFoundException() : Exception("Attendee not found."); ``` -1. Head back to the `AttendeeMutations` class in the `Attendees` directory and add the `CheckInAttendeeAsync` resolver to it: +1. Head back to the `AttendeeMutations` class in the `Attendees` directory, and add the `CheckInAttendeeAsync` resolver to it: ```csharp - [UseApplicationDbContext] - public async Task CheckInAttendeeAsync( + public static async Task CheckInAttendeeAsync( CheckInAttendeeInput input, - [ScopedService] ApplicationDbContext context, + ApplicationDbContext dbContext, CancellationToken cancellationToken) { - Attendee attendee = await context.Attendees.FirstOrDefaultAsync( - t => t.Id == input.AttendeeId, cancellationToken); + var attendee = await dbContext.Attendees.FirstOrDefaultAsync( + a => a.Id == input.AttendeeId, + cancellationToken); if (attendee is null) { - return new CheckInAttendeePayload( - new UserError("Attendee not found.", "ATTENDEE_NOT_FOUND")); + throw new AttendeeNotFoundException(); } - attendee.SessionsAttendees.Add( - new SessionAttendee - { - SessionId = input.SessionId - }); + attendee.SessionsAttendees.Add(new SessionAttendee { SessionId = input.SessionId }); - await context.SaveChangesAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); - return new CheckInAttendeePayload(attendee, input.SessionId); + return attendee; } ``` - Your `AttendeeMutations` class should now look like the following: +1. Start your GraphQL server: - ```csharp - using System.Threading; - using System.Threading.Tasks; - using Microsoft.EntityFrameworkCore; - using ConferencePlanner.GraphQL.Common; - using ConferencePlanner.GraphQL.Data; - using HotChocolate; - using HotChocolate.Types; + ```shell + dotnet run --project GraphQL + ``` - namespace ConferencePlanner.GraphQL.Attendees - { - [ExtendObjectType(Name = "Mutation")] - public class AttendeeMutations - { - [UseApplicationDbContext] - public async Task RegisterAttendeeAsync( - RegisterAttendeeInput input, - [ScopedService] ApplicationDbContext context, - CancellationToken cancellationToken) - { - var attendee = new Attendee - { - FirstName = input.FirstName, - LastName = input.LastName, - UserName = input.UserName, - EmailAddress = input.EmailAddress - }; - - context.Attendees.Add(attendee); - - await context.SaveChangesAsync(cancellationToken); - - return new RegisterAttendeePayload(attendee); - } - - [UseApplicationDbContext] - public async Task CheckInAttendeeAsync( - CheckInAttendeeInput input, - [ScopedService] ApplicationDbContext context, - CancellationToken cancellationToken) - { - Attendee attendee = await context.Attendees.FirstOrDefaultAsync( - t => t.Id == input.AttendeeId, cancellationToken); +1. Validate that you see your new queries and mutations with Banana Cake Pop. - if (attendee is null) - { - return new CheckInAttendeePayload( - new UserError("Attendee not found.", "ATTENDEE_NOT_FOUND")); - } +## Adding an `onSessionScheduled` subscription - attendee.SessionsAttendees.Add( - new SessionAttendee - { - SessionId = input.SessionId - }); +With the base in, we can now focus on putting subscriptions in our GraphQL server. GraphQL subscriptions by default work over WebSockets but could also work over SignalR or gRPC. We'll first update our request pipeline to use WebSockets, and then we'll set up the subscription pub/sub system. After having our server prepared, we'll add the subscriptions to our API. - await context.SaveChangesAsync(cancellationToken); +1. Update the `docker-compose.yml` file with a new Redis service: - return new CheckInAttendeePayload(attendee, input.SessionId); - } - } - } + ```yaml + graphql-workshop-redis: + container_name: graphql-workshop-redis + image: redis:7.4 + networks: [graphql-workshop] + ports: [6379:6379] + volumes: + - type: volume + source: redis-data + target: /data ``` -1. Head over to the `Startup.cs` and register the query and mutation type that we have just added with the schema builder. - - ```csharp - services - .AddGraphQLServer() - .AddQueryType(d => d.Name("Query")) - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddMutationType(d => d.Name("Mutation")) - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddType() - .AddType() - .AddType() - .AddType() - .EnableRelaySupport() - .AddFiltering() - .AddSorting() - .AddDataLoader() - .AddDataLoader(); + ```diff + volumes: + postgres-data: + + redis-data: ``` -1. Start your GraphQL server. - - ```console - dotnet run --project GraphQL - ``` - -1. Validate that you see your new queries and mutations with Banana Cake Pop. - -## Add `onSessionScheduled` Subscription - -With the base in, we now can focus on putting subscriptions on our GraphQL server. GraphQL subscriptions by default work over WebSockets but could also work over SignalR or gRPC. We will first update our request pipeline to use WebSockets, and then we will set up the subscription pub/sub-system. After having our server prepared, we will put in the subscriptions to our API. - -1. Head over to `Startup.cs` and add `app.WebSockets` to the request pipeline. Middleware order is also important with ASP.NET Core, so this middleware needs to come before the GraphQL middleware. - - ```csharp - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } +1. Add a reference to the NuGet package `HotChocolate.Subscriptions.Redis` version `14.0.0-p.139`: + - `dotnet add GraphQL package HotChocolate.Subscriptions.Redis --version 14.0.0-p.139` - app.UseWebSockets(); - app.UseRouting(); +1. Head over to `Program.cs` and add `app.UseWebSockets()` to the request pipeline. Middleware order is also important with ASP.NET Core, so this middleware needs to come before the GraphQL middleware: - app.UseEndpoints(endpoints => - { - endpoints.MapGraphQL(); - }); - } + ```diff + + app.UseWebSockets(); + app.MapGraphQL(); ``` -1. Stay in the `Startup.cs` and add `.AddInMemorySubscriptions();` to the `ConfigureServices` method. +1. Stay in the `Program.cs` file and add Redis subscriptions to the GraphQL configuration: - ```csharp - public void ConfigureServices(IServiceCollection services) - { - services.AddPooledDbContextFactory( - options => options.UseSqlite("Data Source=conferences.db")); - - services - .AddGraphQLServer() - .AddQueryType(d => d.Name("Query")) - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddMutationType(d => d.Name("Mutation")) - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddType() - .AddType() - .AddType() - .AddType() - .EnableRelaySupport() - .AddFiltering() - .AddSorting() - .AddInMemorySubscriptions() - .AddDataLoader() - .AddDataLoader(); - } + ```diff + .AddSorting() + + .AddRedisSubscriptions(_ => ConnectionMultiplexer.Connect("127.0.0.1:6379")) + .AddGraphQLTypes(); ``` - > With `app.UseWebSockets()` we have enabled our server to handle websocket request. With `.AddInMemorySubscriptions();` we have added an in-memory pub/sub system for GraphQL subscriptions to our schema. + With `app.UseWebSockets()` we've enabled our server to handle websocket requests. With `AddRedisSubscriptions(...)` we've added a Redis pub/sub system for GraphQL subscriptions to our schema. -1. Add a new class `SessionSubscriptions` to the `Sessions` directory. +1. Add a new class named `SessionSubscriptions` to the `Sessions` directory: ```csharp - using System.Threading; - using System.Threading.Tasks; using ConferencePlanner.GraphQL.Data; - using ConferencePlanner.GraphQL.DataLoader; - using HotChocolate; - using HotChocolate.Types; - namespace ConferencePlanner.GraphQL.Sessions + namespace ConferencePlanner.GraphQL.Sessions; + + [SubscriptionType] + public static class SessionSubscriptions { - [ExtendObjectType(Name = "Subscription")] - public class SessionSubscriptions + [Subscribe] + [Topic] + public static async Task OnSessionScheduledAsync( + [EventMessage] int sessionId, + SessionByIdDataLoader sessionById, + CancellationToken cancellationToken) { - [Subscribe] - [Topic] - public Task OnSessionScheduledAsync( - [EventMessage] int sessionId, - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) => - sessionById.LoadAsync(sessionId, cancellationToken); + return await sessionById.LoadAsync(sessionId, cancellationToken); } } ``` - > The `[Topic]` attribute can be put on the method or a parameter of the method and will infer the pub/sub-topic for this subscription. + The `[Subscribe]` attribute tells the schema builder that this resolver method needs to be hooked up to the pub/sub system. This means that in the background, the resolver compiler will create a so-called subscribe resolver that handles subscribing to the pub/sub system. - > The `[Subscribe]` attribute tells the schema builder that this resolver method needs to be hooked up to the pub/sub-system. This means that in the background, the resolver compiler will create a so-called subscribe resolver that handles subscribing to the pub/sub-system. + The `[Topic]` attribute can be put on the method or a parameter of the method and will infer the pub/sub topic for this subscription. - > The `[EventMessage]` attribute marks the parameter where the execution engine shall inject the message payload of the pub/sub-system. + The `[EventMessage]` attribute marks the parameter where the execution engine will inject the message payload of the pub/sub system. -1. Head back to the `Startup.cs` and register the `SessionSubscriptions` with the schema builder. - - ```csharp - services - .AddGraphQLServer() - .AddQueryType(d => d.Name("Query")) - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddMutationType(d => d.Name("Mutation")) - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddSubscriptionType(d => d.Name("Subscription")) - .AddTypeExtension() - .AddType() - .AddType() - .AddType() - .AddType() - .EnableRelaySupport() - .AddFiltering() - .AddSorting() - .AddInMemorySubscriptions() - .AddDataLoader() - .AddDataLoader(); - ``` - - The subscription type itself is now registered, but we still need something to trigger the event. So, next, we are going to update our `scheduleSession` resolver to trigger an event. + The subscription type itself is automatically registered, but we still need something to trigger the event. So next, we are going to update our `scheduleSession` resolver to trigger an event. 1. Head over to the `SessionMutations` class in the `Sessions` directory and replace `ScheduleSessionAsync` with the following code: ```csharp - [UseApplicationDbContext] - public async Task ScheduleSessionAsync( + [Error] + [Error] + public static async Task ScheduleSessionAsync( ScheduleSessionInput input, - [ScopedService] ApplicationDbContext context, - [Service]ITopicEventSender eventSender) + ApplicationDbContext dbContext, + ITopicEventSender eventSender, + CancellationToken cancellationToken) { if (input.EndTime < input.StartTime) { - return new ScheduleSessionPayload( - new UserError("endTime has to be larger than startTime.", "END_TIME_INVALID")); + throw new EndTimeInvalidException(); } - Session session = await context.Sessions.FindAsync(input.SessionId); + var session = await dbContext.Sessions.FindAsync([input.SessionId], cancellationToken); if (session is null) { - return new ScheduleSessionPayload( - new UserError("Session not found.", "SESSION_NOT_FOUND")); + throw new SessionNotFoundException(); } session.TrackId = input.TrackId; session.StartTime = input.StartTime; session.EndTime = input.EndTime; - await context.SaveChangesAsync(); + await dbContext.SaveChangesAsync(cancellationToken); await eventSender.SendAsync( nameof(SessionSubscriptions.OnSessionScheduledAsync), - session.Id); + session.Id, + cancellationToken); - return new ScheduleSessionPayload(session); + return session; } ``` - > Our improved resolver now injects `[Service]ITopicEventSender eventSender`. This gives us access to send messages to the underlying pub/sub-system. + Our improved resolver now injects `ITopicEventSender eventSender`. This gives us access to send messages to the underlying pub/sub system. - > After `await context.SaveChangesAsync();` we are sending in a new message. + After `await dbContext.SaveChangesAsync(cancellationToken);`, we are sending a new message. - ```csharp - await eventSender.SendAsync( - nameof(SessionSubscriptions.OnSessionScheduledAsync), - session.Id); - ``` + ```csharp + await eventSender.SendAsync( + nameof(SessionSubscriptions.OnSessionScheduledAsync), + session.Id, + cancellationToken); + ``` - > Since we added the `[Topic]` attribute on our resolver method in the `SessionSubscriptions` class, the topic is now the name of this method. A topic can be anything that can be serialized and has equality implemented so you could also use an object. + Since we added the `[Topic]` attribute on our resolver method in the `SessionSubscriptions` class, the topic is now the name of this method. A topic can be anything that can be serialized and has equality implemented so you could also use an object. -1. Start your GraphQL server. +1. Start your GraphQL server: - ```console - dotnet run --project GraphQL - ``` + ```shell + dotnet run --project GraphQL + ``` 1. Open Banana Cake Pop and refresh the schema. 1. Open a new query tab and add the following subscription query: - ```graphql - subscription { - onSessionScheduled { - title - startTime - } - } - ``` + ```graphql + subscription { + onSessionScheduled { + title + startTime + } + } + ``` - Execute the subscription query. Nothing will happen at this point, and you will just see a loading indicator. + Execute the subscription query. Nothing will happen at this point, and you'll just see a loading indicator. - ![Subscription Waiting for Events](images/31-bcp-subscribe.png) + ![Subscription Waiting for Events](images/31-bcp-subscribe.webp) -1. Open another tab in Banana Cake Pop and add the following query: +1. Open another tab in Banana Cake Pop and add the following document: - ```graphql - query GetSessionsAndTracks { - sessions { - nodes { - id - } - } - tracks { - nodes { - id - } - } - } + ```graphql + query GetSessionsAndTracks { + sessions(first: 1) { + nodes { + id + } + } + tracks(first: 1) { + nodes { + id + } + } + } - mutation ScheduleSession { - scheduleSession( - input: { - sessionId: "U2Vzc2lvbgppMQ==" - trackId: "VHJhY2sKaTE=" - startTime: "2020-08-01T16:00" - endTime: "2020-08-01T17:00" - } - ) { - session { - title - } - } - } - ``` + mutation ScheduleSession { + scheduleSession( + input: { + sessionId: "U2Vzc2lvbjox" + trackId: "VHJhY2s6MQ==" + startTime: "2020-08-01T16:00:00Z" + endTime: "2020-08-01T17:00:00Z" + } + ) { + session { + title + } + } + } + ``` - Execute `GetSessionsAndTracks` first by clicking in the execute link above it. Use the IDs from the response for `ScheduleSession` and execute it once you have filled in the correct IDs. + Execute `GetSessionsAndTracks` first by clicking the `Run` link above it. Use the IDs from the response for `ScheduleSession` and execute it once you have filled in the correct IDs. - ![Subscription Waiting for Events](images/32-bcp-scheduled.png) + ![Subscription Scheduled](images/32-bcp-scheduled.webp) -1. Return to your first query tab (the tab where you specified the subscription query). +1. Return to the first query tab (the tab where you specified the subscription query). - ![Subscription Waiting for Events](images/33-bcp-subscription-result.png) + ![Subscription Result](images/33-bcp-subscription-result.webp) - The event was raised, and our subscription query was executed. We can also see that the loading indicator is still turning since we are still subscribed, and we will get new responses whenever the event is raised. With GraphQL a subscription stream can be infinite or finite. A finite stream will automatically complete whenever the server chooses to complete the topic `ITopicEventSender.CompleteAsync`. + The event was raised, and our subscription query was executed. We can also see that the loading indicator is still turning since we are still subscribed, and we'll get new responses whenever the event is raised. With GraphQL a subscription stream can be infinite or finite. A finite stream will automatically complete whenever the server chooses to complete the topic (`ITopicEventSender.CompleteAsync`). - To stop the subscription from the client-side, click on the stop button right of the loading indicator. + To stop the subscription from the client side, click on the `Cancel` button. -## Add `onAttendeeCheckedIn` subscription +## Adding an `onAttendeeCheckedIn` subscription -The `onSessionScheduled` was quite simple since we did not subscribe to a dynamic topic. Meaning a topic that is defined at the moment we subscribe to it or a topic that depends on the user-context. With `onAttendeeCheckedIn`, we will subscribe to a specific session to see who checked in and how quickly it fills up. +The `onSessionScheduled` subscription was quite simple since we didn't subscribe to a dynamic topic. A dynamic topic refers to a topic that is defined at the moment we subscribe to it, or a topic that depends on the user context. With `onAttendeeCheckedIn`, we'll subscribe to a specific session to see who checked in and how quickly it fills up. 1. Head over to the `AttendeeMutations` class and replace the `CheckInAttendeeAsync` resolver with the following code: ```csharp - [UseApplicationDbContext] - public async Task CheckInAttendeeAsync( + public static async Task CheckInAttendeeAsync( CheckInAttendeeInput input, - [ScopedService] ApplicationDbContext context, - [Service] ITopicEventSender eventSender, + ApplicationDbContext dbContext, + ITopicEventSender eventSender, CancellationToken cancellationToken) { - Attendee attendee = await context.Attendees.FirstOrDefaultAsync( - t => t.Id == input.AttendeeId, cancellationToken); + var attendee = await dbContext.Attendees.FirstOrDefaultAsync( + a => a.Id == input.AttendeeId, + cancellationToken); if (attendee is null) { - return new CheckInAttendeePayload( - new UserError("Attendee not found.", "ATTENDEE_NOT_FOUND")); + throw new AttendeeNotFoundException(); } - attendee.SessionsAttendees.Add( - new SessionAttendee - { - SessionId = input.SessionId - }); + attendee.SessionsAttendees.Add(new SessionAttendee { SessionId = input.SessionId }); - await context.SaveChangesAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); await eventSender.SendAsync( - "OnAttendeeCheckedIn_" + input.SessionId, + $"OnAttendeeCheckedIn_{input.SessionId}", input.AttendeeId, cancellationToken); - return new CheckInAttendeePayload(attendee, input.SessionId); + return attendee; } ``` - In this instance, we are again using our `ITopicEventSender` to send messages to our pub/sub-system. However, we are now creating a string topic combined with parts of the input `input.SessionId` and a string describing the event `OnAttendeeCheckedIn_`. If nobody is subscribed, the messages will just be dropped. + In this instance, we are again using our `ITopicEventSender` to send messages to our pub/sub system. However, we are now creating a string topic that includes the session ID. If nobody is subscribed, the messages will just be dropped. ```csharp await eventSender.SendAsync( - "OnAttendeeCheckedIn_" + input.SessionId, + $"OnAttendeeCheckedIn_{input.SessionId}", input.AttendeeId, cancellationToken); ``` -1. Add a new class `SessionAttendeeCheckIn` to the `Attendees` directory. This will be our subscription payload. +1. Add a new class named `SessionAttendeeCheckIn` to the `Attendees` directory. This will be our subscription payload: ```csharp - using System.Linq; - using System.Threading; - using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using ConferencePlanner.GraphQL.Data; - using ConferencePlanner.GraphQL.DataLoader; - using HotChocolate; - using HotChocolate.Types.Relay; + using ConferencePlanner.GraphQL.Sessions; - namespace ConferencePlanner.GraphQL.Attendees + namespace ConferencePlanner.GraphQL.Attendees; + + public sealed class SessionAttendeeCheckIn(int attendeeId, int sessionId) { - public class SessionAttendeeCheckIn + [ID] + public int AttendeeId { get; } = attendeeId; + + [ID] + public int SessionId { get; } = sessionId; + + public async Task CheckInCountAsync( + ApplicationDbContext dbContext, + CancellationToken cancellationToken) { - public SessionAttendeeCheckIn(int attendeeId, int sessionId) - { - AttendeeId = attendeeId; - SessionId = sessionId; - } - - [ID(nameof(Attendee))] - public int AttendeeId { get; } - - [ID(nameof(Session))] - public int SessionId { get; } - - [UseApplicationDbContext] - public async Task CheckInCountAsync( - [ScopedService] ApplicationDbContext context, - CancellationToken cancellationToken) => - await context.Sessions - .Where(session => session.Id == SessionId) - .SelectMany(session => session.SessionAttendees) - .CountAsync(cancellationToken); - - public Task GetAttendeeAsync( - AttendeeByIdDataLoader attendeeById, - CancellationToken cancellationToken) => - attendeeById.LoadAsync(AttendeeId, cancellationToken); - - public Task GetSessionAsync( - SessionByIdDataLoader sessionById, - CancellationToken cancellationToken) => - sessionById.LoadAsync(SessionId, cancellationToken); + return await dbContext.Sessions + .Where(s => s.Id == SessionId) + .SelectMany(s => s.SessionAttendees) + .CountAsync(cancellationToken); + } + + public async Task GetAttendeeAsync( + AttendeeByIdDataLoader attendeeById, + CancellationToken cancellationToken) + { + return await attendeeById.LoadAsync(AttendeeId, cancellationToken); + } + + public async Task GetSessionAsync( + SessionByIdDataLoader sessionById, + CancellationToken cancellationToken) + { + return await sessionById.LoadAsync(SessionId, cancellationToken); } } ``` -1. Create a new class, `AttendeeSubscriptions` and put it in the `Attendees` directory. +1. Create a new class named `AttendeeSubscriptions` and put it in the `Attendees` directory: ```csharp - using System.Threading; - using System.Threading.Tasks; using ConferencePlanner.GraphQL.Data; - using ConferencePlanner.GraphQL.DataLoader; - using HotChocolate; using HotChocolate.Execution; using HotChocolate.Subscriptions; - using HotChocolate.Types; - using HotChocolate.Types.Relay; - namespace ConferencePlanner.GraphQL.Attendees + namespace ConferencePlanner.GraphQL.Attendees; + + [SubscriptionType] + public static class AttendeeSubscriptions { - [ExtendObjectType(Name = "Subscription")] - public class AttendeeSubscriptions + [Subscribe(With = nameof(SubscribeToOnAttendeeCheckedInAsync))] + public static SessionAttendeeCheckIn OnAttendeeCheckedIn( + [ID] int sessionId, + [EventMessage] int attendeeId) + { + return new SessionAttendeeCheckIn(attendeeId, sessionId); + } + + public static async ValueTask> SubscribeToOnAttendeeCheckedInAsync( + int sessionId, + ITopicEventReceiver eventReceiver, + CancellationToken cancellationToken) { - [Subscribe(With = nameof(SubscribeToOnAttendeeCheckedInAsync))] - public SessionAttendeeCheckIn OnAttendeeCheckedIn( - [ID(nameof(Session))] int sessionId, - [EventMessage] int attendeeId) => - new SessionAttendeeCheckIn(attendeeId, sessionId); - - public async ValueTask> SubscribeToOnAttendeeCheckedInAsync( - int sessionId, - [Service] ITopicEventReceiver eventReceiver, - CancellationToken cancellationToken) => - await eventReceiver.SubscribeAsync( - "OnAttendeeCheckedIn_" + sessionId, cancellationToken); + return await eventReceiver.SubscribeAsync( + $"OnAttendeeCheckedIn_{sessionId}", + cancellationToken); } } ``` - `OnAttendeeCheckedIn` represents our resolver like in the first subscription we built, but now in our `SubscribeAttribute` we are referring to a method called `SubscribeToOnAttendeeCheckedInAsync`. So, instead of letting the system generate a subscribe resolver that handles subscribing to the pub/sub-system we are creating it ourselves in order to control how it is done or event order to filter out events that we do not want to pass down. - - ```csharp - public async ValueTask> SubscribeToOnAttendeeCheckedInAsync( - int sessionId, - [Service] ITopicEventReceiver eventReceiver, - CancellationToken cancellationToken) => - await eventReceiver.SubscribeAsync( - "OnAttendeeCheckedIn_" + sessionId, cancellationToken); - ``` - - The subscribe resolver is using `ITopicEventReceiver` to subscribe to a topic. A subscribe resolver can return `IAsyncEnumerable`, `IEnumerable` or `IObservable` to represent the subscription stream. The subscribe resolver has access to all the arguments that the actual resolver has access to. - - 1. Head back to the `Startup.cs` and register this new subscription type with the schema builder. + `OnAttendeeCheckedIn` represents our resolver like in the first subscription that we built, but now in our `Subscribe` attribute we are referring to a method named `SubscribeToOnAttendeeCheckedInAsync`. So, instead of letting the system generate a subscribe resolver that handles subscribing to the pub/sub system, we are creating it ourselves in order to control how it's done, or to filter out events that we don't want to pass down. ```csharp - services - .AddGraphQLServer() - .AddQueryType(d => d.Name("Query")) - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddMutationType(d => d.Name("Mutation")) - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddSubscriptionType(d => d.Name("Subscription")) - .AddTypeExtension() - .AddTypeExtension() - .AddType() - .AddType() - .AddType() - .AddType() - .EnableRelaySupport() - .AddFiltering() - .AddSorting() - .AddInMemorySubscriptions() - .AddDataLoader() - .AddDataLoader(); + public static async ValueTask> SubscribeToOnAttendeeCheckedInAsync( + int sessionId, + ITopicEventReceiver eventReceiver, + CancellationToken cancellationToken) + { + return await eventReceiver.SubscribeAsync( + $"OnAttendeeCheckedIn_{sessionId}", + cancellationToken); + } ``` - 1. Start your GraphQL server again. + The subscribe resolver is using `ITopicEventReceiver` to subscribe to a topic. A subscribe resolver can return `IAsyncEnumerable`, `IEnumerable`, or `IObservable` to represent the subscription stream. The subscribe resolver has access to all of the arguments that the actual resolver has access to. + +1. Start your GraphQL server again: - ```console - dotnet run --project GraphQL - ``` + ```shell + dotnet run --project GraphQL + ``` - 1. Open a new tab and put the following query document in: +1. Open a new tab and add the following document: - ```graphql - query GetSessions { - sessions { - nodes { - id - } - } - } + ```graphql + query GetSessions { + sessions(first: 1) { + nodes { + id + } + } + } - mutation RegisterAttendee { - registerAttendee( - input: { - emailAddress: "michael@chillicream.com" - firstName: "michael" - lastName: "staib" - userName: "michael" - } - ) { - attendee { - id - } - } - } + mutation RegisterAttendee { + registerAttendee( + input: { + firstName: "Michael" + lastName: "Staib" + username: "michael" + emailAddress: "michael@chillicream.com" + } + ) { + attendee { + id + } + } + } - mutation CheckInAttendee { - checkInAttendee( - input: { attendeeId: "QXR0ZW5kZWUKaTE=", sessionId: "U2Vzc2lvbgppMQ==" } - ) { - attendee { - userName - } - session { - title - } - } - } - ``` + mutation CheckInAttendee { + checkInAttendee( + input: { + attendeeId: "QXR0ZW5kZWU6Mg==" + sessionId: "U2Vzc2lvbjox" + } + ) { + attendee { + username + } + } + } + ``` - Execute `GetSessions` first the resulting session ID and feed it into the `CheckInAttendee` operation. + Execute `GetSessions` first, take the resulting session ID, and feed it into the `CheckInAttendee` operation. - ![Execute GetSessions](images/34-bcp-GetSessions.png) + ![Execute GetSessions](images/34-bcp-get-sessions.webp) - Next, Execute `RegisterAttendee` take the resulting attendee ID and feed it into the `CheckInAttendee` operation. + Next, execute `RegisterAttendee`, take the resulting attendee ID, and feed it into the `CheckInAttendee` operation. - ![Execute RegisterAttendee](images/35-bcp-RegisterAttendee.png) + ![Execute RegisterAttendee](images/35-bcp-register-attendee.webp) - 1. Open another tab in Banana Cake Pop and add the following query document: +1. Open another tab in Banana Cake Pop and add the following document: - ```graphql - subscription OnAttendeeCheckedIn { - onAttendeeCheckedIn(sessionId: "U2Vzc2lvbgppMQ==") { - checkInCount - attendee { - userName - } - } - } - ``` + ```graphql + subscription OnAttendeeCheckedIn { + onAttendeeCheckedIn(sessionId: "U2Vzc2lvbjox") { + checkInCount + attendee { + username + } + } + } + ``` - Feed-in the session ID you gathered earlier and pass it into the `sessionId` argument of `OnAttendeeCheckedIn`. + Take the session ID that you gathered earlier and pass it into the `sessionId` argument of `OnAttendeeCheckedIn`. - Execute `OnAttendeeCheckedIn`, again nothing will happen at this point, and the query tab is just waiting for incoming messages. + Execute `OnAttendeeCheckedIn`. Again, nothing will happen at this point, and the query tab is just waiting for incoming messages. - ![Execute OnAttendeeCheckedIn](images/37-bcp-OnAttendeeCheckedIn.png) + ![Execute OnAttendeeCheckedIn](images/36-bcp-on-attendee-checked-in.webp) - 1. Get back to the earlier tab and execute the `CheckInAttendee` operation. +1. Go back to the previous tab and execute the `CheckInAttendee` operation. - ![Execute CheckInAttendee](images/36-bcp-CheckInAttendee.png) + ![Execute CheckInAttendee](images/37-bcp-check-in-attendee.webp) - 1. Click on the subscription tab to verify that we have received the message that an attendee has checked into our session. +1. Click on the 2nd tab to verify that we've received the message that an attendee has checked into our session. - ![OnAttendeeCheckedIn Received Result](images/38-bcp-OnAttendeeCheckedIn.png) + ![OnAttendeeCheckedIn Received Result](images/38-bcp-on-attendee-checked-in.webp) ## Summary -In this session, we have learned how we can use GraphQL subscription to provide real-time events. GraphQL makes it easy to work with real-time data since we can specify what data we want to receive when an event happens on our system. +In this session, we've learned how we can use GraphQL subscriptions to provide real-time events. GraphQL makes it easy to work with real-time data since we can specify what data we want to receive when an event occurs in our system. + +[**<< Session #6 - Adding complex filter capabilities**](6-adding-complex-filter-capabilities.md) | [**Session #8 - Testing the GraphQL server >>**](8-testing-the-graphql-server.md) -[**<< Session #6 - Adding complex filter capabilities**](6-adding-complex-filter-capabilities.md) | [**Session #8 - Testing the GraphQL server >>**](8-testing-the-graphql-server.md) + diff --git a/docs/images/31-bcp-subscribe.png b/docs/images/31-bcp-subscribe.png deleted file mode 100644 index 534e3e3..0000000 Binary files a/docs/images/31-bcp-subscribe.png and /dev/null differ diff --git a/docs/images/31-bcp-subscribe.webp b/docs/images/31-bcp-subscribe.webp new file mode 100644 index 0000000..e5de8f1 Binary files /dev/null and b/docs/images/31-bcp-subscribe.webp differ diff --git a/docs/images/32-bcp-scheduled.png b/docs/images/32-bcp-scheduled.png deleted file mode 100644 index f4bc4cc..0000000 Binary files a/docs/images/32-bcp-scheduled.png and /dev/null differ diff --git a/docs/images/32-bcp-scheduled.webp b/docs/images/32-bcp-scheduled.webp new file mode 100644 index 0000000..9ba1155 Binary files /dev/null and b/docs/images/32-bcp-scheduled.webp differ diff --git a/docs/images/33-bcp-subscription-result.png b/docs/images/33-bcp-subscription-result.png deleted file mode 100644 index eb251be..0000000 Binary files a/docs/images/33-bcp-subscription-result.png and /dev/null differ diff --git a/docs/images/33-bcp-subscription-result.webp b/docs/images/33-bcp-subscription-result.webp new file mode 100644 index 0000000..7ea85b4 Binary files /dev/null and b/docs/images/33-bcp-subscription-result.webp differ diff --git a/docs/images/34-bcp-GetSessions.png b/docs/images/34-bcp-GetSessions.png deleted file mode 100644 index e915077..0000000 Binary files a/docs/images/34-bcp-GetSessions.png and /dev/null differ diff --git a/docs/images/34-bcp-get-sessions.webp b/docs/images/34-bcp-get-sessions.webp new file mode 100644 index 0000000..892c7de Binary files /dev/null and b/docs/images/34-bcp-get-sessions.webp differ diff --git a/docs/images/35-bcp-RegisterAttendee.png b/docs/images/35-bcp-RegisterAttendee.png deleted file mode 100644 index cfe7226..0000000 Binary files a/docs/images/35-bcp-RegisterAttendee.png and /dev/null differ diff --git a/docs/images/35-bcp-register-attendee.webp b/docs/images/35-bcp-register-attendee.webp new file mode 100644 index 0000000..cbbfc2e Binary files /dev/null and b/docs/images/35-bcp-register-attendee.webp differ diff --git a/docs/images/36-bcp-CheckInAttendee.png b/docs/images/36-bcp-CheckInAttendee.png deleted file mode 100644 index 63b56a5..0000000 Binary files a/docs/images/36-bcp-CheckInAttendee.png and /dev/null differ diff --git a/docs/images/36-bcp-on-attendee-checked-in.webp b/docs/images/36-bcp-on-attendee-checked-in.webp new file mode 100644 index 0000000..b8bcadb Binary files /dev/null and b/docs/images/36-bcp-on-attendee-checked-in.webp differ diff --git a/docs/images/37-bcp-OnAttendeeCheckedIn.png b/docs/images/37-bcp-OnAttendeeCheckedIn.png deleted file mode 100644 index d423463..0000000 Binary files a/docs/images/37-bcp-OnAttendeeCheckedIn.png and /dev/null differ diff --git a/docs/images/37-bcp-check-in-attendee.webp b/docs/images/37-bcp-check-in-attendee.webp new file mode 100644 index 0000000..75905e2 Binary files /dev/null and b/docs/images/37-bcp-check-in-attendee.webp differ diff --git a/docs/images/38-bcp-OnAttendeeCheckedIn.png b/docs/images/38-bcp-OnAttendeeCheckedIn.png deleted file mode 100644 index 8779e39..0000000 Binary files a/docs/images/38-bcp-OnAttendeeCheckedIn.png and /dev/null differ diff --git a/docs/images/38-bcp-on-attendee-checked-in.webp b/docs/images/38-bcp-on-attendee-checked-in.webp new file mode 100644 index 0000000..f2449c4 Binary files /dev/null and b/docs/images/38-bcp-on-attendee-checked-in.webp differ