In order to expand our GraphQL server model further we have several more data models to add, and unfortunately it's a little mechanical. You can copy the following classes manually, or open the session 2 solution.
-
Create an
Attendee.cs
class in theData
directory with the following code:using System.ComponentModel.DataAnnotations; namespace ConferencePlanner.GraphQL.Data; public sealed class Attendee { public int Id { get; init; } [StringLength(200)] public required string FirstName { get; init; } [StringLength(200)] public required string LastName { get; init; } [StringLength(200)] public required string Username { get; init; } [StringLength(256)] public string? EmailAddress { get; init; } public ICollection<SessionAttendee> SessionsAttendees { get; init; } = new List<SessionAttendee>(); }
-
Create a
Session.cs
class with the following code:using System.ComponentModel.DataAnnotations; namespace ConferencePlanner.GraphQL.Data; public sealed class Session { public int Id { get; init; } [StringLength(200)] public required string Title { get; init; } [StringLength(4000)] public string? Abstract { get; init; } public DateTimeOffset? StartTime { 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; public int? TrackId { get; set; } public ICollection<SessionSpeaker> SessionSpeakers { get; init; } = new List<SessionSpeaker>(); public ICollection<SessionAttendee> SessionAttendees { get; init; } = new List<SessionAttendee>(); public Track? Track { get; init; } }
-
Create a
Track.cs
class with the following code:using System.ComponentModel.DataAnnotations; namespace ConferencePlanner.GraphQL.Data; public sealed class Track { public int Id { get; init; } [StringLength(200)] public required string Name { get; set; } public ICollection<Session> Sessions { get; init; } = new List<Session>(); }
-
Create a
SessionAttendee.cs
class with the following code:namespace ConferencePlanner.GraphQL.Data; public sealed class SessionAttendee { public int SessionId { get; init; } public Session Session { get; init; } = null!; public int AttendeeId { get; init; } public Attendee Attendee { get; init; } = null!; }
-
Create a
SessionSpeaker.cs
class with the following code:namespace ConferencePlanner.GraphQL.Data; public sealed class SessionSpeaker { public int SessionId { get; init; } public Session Session { get; init; } = null!; public int SpeakerId { get; init; } public Speaker Speaker { get; init; } = null!; }
-
Next, modify the
Speaker
class and add the following property to it:public ICollection<SessionSpeaker> SessionSpeakers { get; init; } = new List<SessionSpeaker>();
The class should now look like the following:
using System.ComponentModel.DataAnnotations; namespace ConferencePlanner.GraphQL.Data; public sealed class Speaker { public int Id { get; init; } [StringLength(200)] public required string Name { get; init; } [StringLength(4000)] public string? Bio { get; init; } [StringLength(1000)] public string? Website { get; init; } public ICollection<SessionSpeaker> SessionSpeakers { get; init; } = new List<SessionSpeaker>(); }
-
Last but not least, update the
ApplicationDbContext
with the following code:using Microsoft.EntityFrameworkCore; namespace ConferencePlanner.GraphQL.Data; public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options) { public DbSet<Attendee> Attendees { get; init; } public DbSet<Session> Sessions { get; init; } public DbSet<Speaker> Speakers { get; init; } public DbSet<Track> Tracks { get; init; } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder .Entity<Attendee>() .HasIndex(a => a.Username) .IsUnique(); // Many-to-many: Session <-> Attendee modelBuilder .Entity<SessionAttendee>() .HasKey(sa => new { sa.SessionId, sa.AttendeeId }); // Many-to-many: Speaker <-> Session modelBuilder .Entity<SessionSpeaker>() .HasKey(ss => new { ss.SessionId, ss.SpeakerId }); } }
Now that we have all of our models in we need to create another migration and update our database.
-
First, validate your project by building it:
dotnet build GraphQL
-
Next, generate a new migration for the database:
dotnet ef migrations add Refactoring --project GraphQL
-
Lastly, update the database with the new migration:
dotnet ef database update --project GraphQL
After having everything in let's have a look at our schema and see if something changed.
-
Start your server:
dotnet run --project GraphQL
-
Open Nitro.
-
Head over to the
Schema Reference
tab and have a look at the speaker.- Note: You might have to reload the schema. You can do so by clicking the
Reload Schema
button in the upper right corner.
- Note: You might have to reload the schema. You can do so by clicking the
The idea of a DataLoader is to batch multiple requests into one call to the database.
While we could write DataLoaders as individual classes, there is also a source generator to remove some of the boilerplate code.
-
In order to use the experimental
ISelectorBuilder
for DataLoader projections in Hot Chocolate 14, we need to suppress warningGD0001
. Add the following to theGraphQL.csproj
file:<RootNamespace>ConferencePlanner.GraphQL</RootNamespace> + <NoWarn>GD0001</NoWarn>
-
Add a new class named
DataLoaders
with the following code:using ConferencePlanner.GraphQL.Data; using GreenDonut.Selectors; using Microsoft.EntityFrameworkCore; namespace ConferencePlanner.GraphQL; public static class DataLoaders { [DataLoader] public static async Task<IReadOnlyDictionary<int, Speaker>> SpeakerByIdAsync( IReadOnlyList<int> ids, ApplicationDbContext dbContext, ISelectorBuilder selector, CancellationToken cancellationToken) { return await dbContext.Speakers .AsNoTracking() .Where(s => ids.Contains(s.Id)) .Select(s => s.Id, selector) .ToDictionaryAsync(s => s.Id, cancellationToken); } }
The source generator will generate DataLoader classes for methods with the
[DataLoader]
attribute. -
Add a new method named
GetSpeakerAsync
to yourQueries.cs
file:[Query] public static async Task<Speaker?> GetSpeakerAsync( int id, ISpeakerByIdDataLoader speakerById, ISelection selection, CancellationToken cancellationToken) { return await speakerById.Select(selection).LoadAsync(id, cancellationToken); }
The
Queries.cs
file should now look like the following:using ConferencePlanner.GraphQL.Data; using GreenDonut.Selectors; using HotChocolate.Execution.Processing; using Microsoft.EntityFrameworkCore; namespace ConferencePlanner.GraphQL; public static class Queries { [Query] public static async Task<IEnumerable<Speaker>> GetSpeakersAsync( ApplicationDbContext dbContext, CancellationToken cancellationToken) { return await dbContext.Speakers.AsNoTracking().ToListAsync(cancellationToken); } [Query] public static async Task<Speaker?> GetSpeakerAsync( int id, ISpeakerByIdDataLoader speakerById, ISelection selection, CancellationToken cancellationToken) { return await speakerById.Select(selection).LoadAsync(id, cancellationToken); } }
-
Let's have a look at the new schema with Nitro. For this, start your server and refresh Nitro:
dotnet run --project GraphQL
-
Now, test if the new field works correctly:
query GetSpecificSpeakerById { a: speaker(id: 1) { name } b: speaker(id: 1) { name } }
If you look at the console output, you'll see that only a single SQL query is executed, instead of one for each speaker, and that only the
Name
andId
columns are selected, instead of all columns in the table, by using DataLoader projections:info: Microsoft.EntityFrameworkCore.Database.Command[20101] Executed DbCommand (1ms) [Parameters=[@__ids_0='?' (DbType = Object)], CommandType='Text', CommandTimeout='30'] SELECT s."Name", s."Id" FROM "Speakers" AS s WHERE s."Id" = ANY (@__ids_0)
At the moment, we are purely inferring the schema from our C# classes. In some cases where we have everything under control, this might be a good thing, and everything is okay.
But if we, for instance, have some parts of the API not under our control and want to change the GraphQL schema representation of these APIs, type extensions can help. With Hot Chocolate, we can mix in these type extensions where we need them.
In our specific case, we want to make the GraphQL API nicer and remove the relationship objects like SessionSpeaker
.
-
First let's add a new DataLoader in order to efficiently fetch sessions by speaker ID. Add the following method to the
DataLoaders
class:[DataLoader] public static async Task<IReadOnlyDictionary<int, Session[]>> SessionsBySpeakerIdAsync( IReadOnlyList<int> speakerIds, ApplicationDbContext dbContext, ISelectorBuilder selector, CancellationToken cancellationToken) { return await dbContext.Speakers .AsNoTracking() .Where(s => speakerIds.Contains(s.Id)) .Select(s => s.Id, s => s.SessionSpeakers.Select(ss => ss.Session), selector) .ToDictionaryAsync(r => r.Key, r => r.Value.ToArray(), cancellationToken); }
-
Create a new directory named
Types
:mkdir GraphQL/Types
-
Create a new class named
SpeakerType
in theTypes
directory, with the following code:using ConferencePlanner.GraphQL.Data; using GreenDonut.Selectors; using HotChocolate.Execution.Processing; namespace ConferencePlanner.GraphQL.Types; [ObjectType<Speaker>] public static partial class SpeakerType { [BindMember(nameof(Speaker.SessionSpeakers))] public static async Task<IEnumerable<Session>> GetSessionsAsync( [Parent] Speaker speaker, ISessionsBySpeakerIdDataLoader sessionsBySpeakerId, ISelection selection, CancellationToken cancellationToken) { return await sessionsBySpeakerId .Select(selection) .LoadRequiredAsync(speaker.Id, cancellationToken); } }
In this type extension, we replace the existing
sessionSpeakers
field (propertySessionSpeakers
), with a new field namedsessions
(methodGetSessionsAsync
), using the[BindMember]
attribute. The new field exposes the sessions associated with the speaker. -
The new GraphQL representation of our speaker type is now:
type Speaker { sessions: [Session!]! id: Int! name: String! bio: String website: String }
-
Next, create another class named
SessionType
in theTypes
directory, with the following code:using ConferencePlanner.GraphQL.Data; namespace ConferencePlanner.GraphQL.Types; [ObjectType<Session>] public static partial class SessionType { public static TimeSpan Duration([Parent("StartTime EndTime")] Session session) => session.Duration; }
In this type extension, we add a resolver for the
duration
field of theSession
type, so that we can specify its requirements using the[Parent]
attribute. In this case,StartTime
andEndTime
are required in order to compute theDuration
in theSession
class. -
Start your GraphQL server again:
dotnet run --project GraphQL
-
Go back to Nitro, refresh the schema, and execute the following query:
query GetSpeakerWithSessions { speaker(id: 1) { name sessions { title duration } } }
Since we do not have any data for sessions yet the server will return an empty list of sessions. Still, our server works already and we will soon be able to add more data.
In this session, we've added DataLoaders to our GraphQL API and learned what DataLoaders are. We've also looked at a new way to extend our GraphQL types with type extensions, which lets us change the shape of types that we don't want to annotate with GraphQL attributes.
<< Session #1 - Creating a new GraphQL server project | Session #3 - GraphQL schema design approaches >>