Skip to content

Latest commit

 

History

History
472 lines (343 loc) · 14.4 KB

2-understanding-data-loader.md

File metadata and controls

472 lines (343 loc) · 14.4 KB

Understanding DataLoader

Adding the remaining data models

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.

  1. Create an Attendee.cs class in the Data 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>();
    }
  2. 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; }
    }
  3. 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>();
    }
  4. 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!;
    }
  5. 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!;
    }
  6. 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>();
    }
  7. 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.

  1. First, validate your project by building it:

    dotnet build GraphQL
  2. Next, generate a new migration for the database:

    dotnet ef migrations add Refactoring --project GraphQL
  3. 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.

  1. Start your server:

    dotnet run --project GraphQL
  2. Open Nitro.

  3. 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.

    Connect to GraphQL server with Nitro

Adding a DataLoader

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.

  1. In order to use the experimental ISelectorBuilder for DataLoader projections in Hot Chocolate 14, we need to suppress warning GD0001. Add the following to the GraphQL.csproj file:

      <RootNamespace>ConferencePlanner.GraphQL</RootNamespace>
    + <NoWarn>GD0001</NoWarn>
  2. 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.

  3. Add a new method named GetSpeakerAsync to your Queries.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);
        }
    }
  4. Let's have a look at the new schema with Nitro. For this, start your server and refresh Nitro:

    dotnet run --project GraphQL

    Connect to GraphQL server with Nitro

  5. Now, test if the new field works correctly:

    query GetSpecificSpeakerById {
      a: speaker(id: 1) {
        name
      }
      b: speaker(id: 1) {
        name
      }
    }

    Connect to GraphQL server with Nitro

    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 and Id 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)

Type extensions

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.

  1. 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);
    }
  2. Create a new directory named Types:

    mkdir GraphQL/Types
  3. Create a new class named SpeakerType in the Types 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 (property SessionSpeakers), with a new field named sessions (method GetSessionsAsync), using the [BindMember] attribute. The new field exposes the sessions associated with the speaker.

  4. The new GraphQL representation of our speaker type is now:

    type Speaker {
      sessions: [Session!]!
      id: Int!
      name: String!
      bio: String
      website: String
    }
  5. Next, create another class named SessionType in the Types 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 the Session type, so that we can specify its requirements using the [Parent] attribute. In this case, StartTime and EndTime are required in order to compute the Duration in the Session class.

  6. Start your GraphQL server again:

    dotnet run --project GraphQL
  7. 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.

Summary

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 >>