How to achieve optimized many to many querying in GraphQL (HotChocolate)

Viewed 227

Imagining that I have a N-N relationship between Student and the Course, .NET structure is typical N-N translated to CLR types Student, StudentCourse and Course.

Desired result:

What I want to achieve when querying for all students like this ...

query { 
 allStudents { 
     name,
     courses { 
         name,
         rating
       }
   }
}

... Is to avoid N+1 graph issues.

Instead I would like to end end up with the query similar to this one:

SELECT s.Name
      FROM [Schema].[Student] AS [s]
      LEFT JOIN (
          SELECT course_id, student_id
          FROM [Relationships].[student_course] AS [c]
          INNER JOIN [Schema].[course] AS [c0] ON [c].[course_id] = [c0].[id]
      ) AS [t] ON [s].[id] = [t].[student_id]
      WHERE [m].[id] IN (1, 2, 4, 8, 10, 12) -- All student id's are loaded with data loader?
      ORDER BY [m].[id], [t].[id]

Current implementation glimpse:

I created a resolver GetCoursesForStudent for batch fetching course for student.

 public async Task<Course> GetCoursesForStudent([Parent] Student student, 
                                                    CoursesForStudentDataLoader dataLoader, 
                                                    IResolverContext context, 
                                                    CancellationToken cancellationToken = default!)
    {
// This is generating a SELECT for each student, maybe load in a batch somehow?
       var courseIds = await service.GetCourseIdsForStudent(student.Id, cancellationToken);

       return await dataLoader.LoadAsync(student.Id, cancellationToken);
    }

And the data loader

public class CoursesForStudentDataLoader : BatchDataLoader<int, Course>
    {
        public CoursesForStudentDataLoader(IBatchScheduler batchScheduler) 
            : base(batchScheduler)
        {
        }

        protected override async Task<IReadOnlyDictionary<int, Course>> LoadBatchAsync(IReadOnlyList<int> keys, 
CancellationToken cancellationToken)
        {
            var courses = repo.Queryable()
                           .Where(x => keys.Contains(x.StudentId))
                        .Select(item => item.Course);

            var fetched = await courses.ToDictionaryAsync(key => key.Id, cancellationToken);

            // fetched contains all the courses!
            return fetched;
        }
    }

And object type definition with the resolver for companies.

protected override void Configure(IObjectTypeDescriptor<Student> descriptor)
    {
        descriptor.Field<StudentResolvers>(t => 
                        t.GetCoursesForStudent(default!, default!, default!, default!)).Name("courses")
                            .Type<ListType<NonNullType<CourseObjectType>>>();
    }

Current results:

Currently this generates a sql query for each student to fetch the course id's for each student. (N+1 issue) And then it uses a data loader to buffer course ids and executes the query to fetch the courses only once. (N+1 avoided, and this looks good).

So I end up with the following sql:

  SELECT [c].[course_id]
      FROM [Relationships].[student_course] AS [c]
      WHERE [c].[student_id] = @__studentId_0
  
  SELECT [c].[course_id]
      FROM [Relationships].[student_course] AS [c]
      WHERE [c].[student_id] = @__studentId_0

  SELECT [c].[course_id]
      FROM [Relationships].[student_course] AS [c]
      WHERE [c].[student_id] = @__studentId_0

... - N (clearly undesirable)


  SELECT [c].[id], [c].[name], [c].[rating]
  FROM [Schema].[course] AS [c]
  WHERE [c].[id] IN (7, 9, 12)

So I wonder what is the way to go with this, is it even possible or I am misinterpreting something.

0 Answers
Related