From 24ff9afd0daf936a4d07c7706c6e815632d74a17 Mon Sep 17 00:00:00 2001 From: Natalya Arbit Date: Sat, 12 Jan 2019 16:39:23 -0800 Subject: [PATCH] Added Connection to PersonQuery to page the result set. --- .../Dapper.GraphQL.Test.csproj | 2 + Dapper.GraphQL.Test/GraphQL/Cursor.cs | 65 ++++++ Dapper.GraphQL.Test/GraphQL/PersonQuery.cs | 103 ++++++++- Dapper.GraphQL.Test/GraphQLTests.cs | 55 +++++ Dapper.GraphQL.Test/Models/Person.cs | 1 + .../QueryBuilders/PersonQueryBuilder.cs | 7 +- .../QueryBuilders/QuerybuilderHelper.cs | 58 +++++ .../Repositories/PersonRepository.cs | 205 ++++++++++++++++++ Dapper.GraphQL.Test/Sql/1-Create.sql | 1 + Dapper.GraphQL.Test/Sql/2-Data.sql | 8 +- Dapper.GraphQL.Test/TestFixture.cs | 10 + 11 files changed, 504 insertions(+), 11 deletions(-) create mode 100644 Dapper.GraphQL.Test/GraphQL/Cursor.cs create mode 100644 Dapper.GraphQL.Test/QueryBuilders/QuerybuilderHelper.cs create mode 100644 Dapper.GraphQL.Test/Repositories/PersonRepository.cs diff --git a/Dapper.GraphQL.Test/Dapper.GraphQL.Test.csproj b/Dapper.GraphQL.Test/Dapper.GraphQL.Test.csproj index f4efc07..1fb9dde 100644 --- a/Dapper.GraphQL.Test/Dapper.GraphQL.Test.csproj +++ b/Dapper.GraphQL.Test/Dapper.GraphQL.Test.csproj @@ -10,6 +10,8 @@ 0.4.2.0 0.4.2-beta + + latest diff --git a/Dapper.GraphQL.Test/GraphQL/Cursor.cs b/Dapper.GraphQL.Test/GraphQL/Cursor.cs new file mode 100644 index 0000000..50f7d81 --- /dev/null +++ b/Dapper.GraphQL.Test/GraphQL/Cursor.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace Dapper.GraphQL.Test.GraphQL +{ + public static class Cursor + { + public static T FromCursor(string cursor) + { + if (string.IsNullOrEmpty(cursor)) + { + return default; + } + + string decodedValue; + try + { + decodedValue = Base64Decode(cursor); + } + catch (FormatException) + { + return default; + } + + return (T)Convert.ChangeType(decodedValue, Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T), CultureInfo.InvariantCulture); + } + + public static (string firstCursor, string lastCursor) GetFirstAndLastCursor( + IEnumerable enumerable, + Func getCursorProperty) + { + if (getCursorProperty == null) + { + throw new ArgumentNullException(nameof(getCursorProperty)); + } + + if (enumerable == null || enumerable.Count() == 0) + { + return (null, null); + } + + var firstCursor = ToCursor(getCursorProperty(enumerable.First())); + var lastCursor = ToCursor(getCursorProperty(enumerable.Last())); + + return (firstCursor, lastCursor); + } + + public static string ToCursor(T value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + return Base64Encode(value.ToString()); + } + + private static string Base64Decode(string value) => Encoding.UTF8.GetString(Convert.FromBase64String(value)); + + private static string Base64Encode(string value) => Convert.ToBase64String(Encoding.UTF8.GetBytes(value)); + } +} diff --git a/Dapper.GraphQL.Test/GraphQL/PersonQuery.cs b/Dapper.GraphQL.Test/GraphQL/PersonQuery.cs index e311738..b036632 100644 --- a/Dapper.GraphQL.Test/GraphQL/PersonQuery.cs +++ b/Dapper.GraphQL.Test/GraphQL/PersonQuery.cs @@ -1,21 +1,33 @@ -using Dapper.GraphQL.Test.EntityMappers; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper.GraphQL.Test.EntityMappers; using Dapper.GraphQL.Test.Models; +using GraphQL.Builders; using GraphQL.Types; +using GraphQL.Types.Relay.DataObjects; using Microsoft.Extensions.DependencyInjection; -using System; -using System.Data; -using System.Data.Common; -using System.Linq; +using Dapper.GraphQL.Test.Repositories; + namespace Dapper.GraphQL.Test.GraphQL { public class PersonQuery : ObjectGraphType { + private const int MaxPageSize = 10; + private readonly IPersonRepository _personRepository; + public PersonQuery( IQueryBuilder personQueryBuilder, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + IPersonRepository personRepository) { + _personRepository = personRepository; + Field>( "people", description: "A list of people.", @@ -99,6 +111,85 @@ public PersonQuery( } } ); + + Connection() + .Name("personConnection") + .Description("Gets pages of Person objects.") + // Enable the last and before arguments to do paging in reverse. + .Bidirectional() + // Set the maximum size of a page, use .ReturnAll() to set no maximum size. + .PageSize(MaxPageSize) + .ResolveAsync(context => ResolveConnection(context, personQueryBuilder)); + } + + private async Task ResolveConnection(ResolveConnectionContext context, IQueryBuilder personQueryBuilder) + { + _personRepository.Context = context; + + var first = context.First; + var afterCursor = Cursor.FromCursor(context.After); + var last = context.Last; + var beforeCursor = Cursor.FromCursor(context.Before); + var cancellationToken = context.CancellationToken; + + var getPersonTask = GetPeople(first, afterCursor, last, beforeCursor, cancellationToken); + var getHasNextPageTask = GetHasNextPage(first, afterCursor, cancellationToken); + var getHasPreviousPageTask = GetHasPreviousPage(last, beforeCursor, cancellationToken); + var totalCountTask = _personRepository.GetTotalCount(cancellationToken); + + await Task.WhenAll(getPersonTask, getHasNextPageTask, getHasPreviousPageTask, totalCountTask); + var people = getPersonTask.Result; + var hasNextPage = getHasNextPageTask.Result; + var hasPreviousPage = getHasPreviousPageTask.Result; + var totalCount = totalCountTask.Result; + var (firstCursor, lastCursor) = Cursor.GetFirstAndLastCursor(people, x => x.CreateDate); + + return new Connection() + { + Edges = people + .Select(x => + new Edge() + { + Cursor = Cursor.ToCursor(x.CreateDate), + Node = x + }) + .ToList(), + PageInfo = new PageInfo() + { + HasNextPage = hasNextPage, + HasPreviousPage = hasPreviousPage, + StartCursor = firstCursor, + EndCursor = lastCursor, + }, + TotalCount = totalCount, + }; + } + + private async Task GetHasNextPage( + int? first, + DateTime? afterCursor, + CancellationToken cancellationToken) + { + return first.HasValue ? await _personRepository.GetHasNextPage(first, afterCursor, cancellationToken) : false; + } + + private async Task GetHasPreviousPage( + int? last, + DateTime? beforeCursor, + CancellationToken cancellationToken) + { + return last.HasValue ? await _personRepository.GetHasPreviousPage(last, beforeCursor, cancellationToken) : false; + } + + private Task> GetPeople( + int? first, + DateTime? afterCursor, + int? last, + DateTime? beforeCursor, + CancellationToken cancellationToken) + { + return first.HasValue ? _personRepository.GetPeople(first, afterCursor, cancellationToken) : + _personRepository.GetPeopleReversed(last, beforeCursor, cancellationToken); } } } \ No newline at end of file diff --git a/Dapper.GraphQL.Test/GraphQLTests.cs b/Dapper.GraphQL.Test/GraphQLTests.cs index cdd2996..60ca885 100644 --- a/Dapper.GraphQL.Test/GraphQLTests.cs +++ b/Dapper.GraphQL.Test/GraphQLTests.cs @@ -366,5 +366,60 @@ public async Task SimplePersonInsert() Assert.True(fixture.JsonEquals(expectedJson, json)); } + + [Fact(DisplayName = "People connection query should succeed")] + public async Task PeopleConnectionQuery() + { + var json = await fixture.QueryGraphQLAsync(@" +query { + personConnection(first:2) { + edges { + node { + firstName + lastName + } + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + } +}"); + + var expectedJson = @" +{ + 'data': { + 'personConnection': { + 'edges': [ + { + 'node': { + 'firstName': 'Hyrum', + 'lastName': 'Clyde' + }, + 'cursor': 'MS8xLzIwMTkgMTI6MDA6MDAgQU0=' + }, + { + 'node': { + 'firstName': 'Doug', + 'lastName': 'Day' + }, + 'cursor': 'MS8yLzIwMTkgMTI6MDA6MDAgQU0=' + } + ], + 'pageInfo': { + 'hasNextPage': true, + 'hasPreviousPage': false, + 'endCursor': 'MS8yLzIwMTkgMTI6MDA6MDAgQU0=', + 'startCursor': 'MS8xLzIwMTkgMTI6MDA6MDAgQU0=' + } + } + } +}"; + + Assert.True(fixture.JsonEquals(expectedJson, json)); + } } } \ No newline at end of file diff --git a/Dapper.GraphQL.Test/Models/Person.cs b/Dapper.GraphQL.Test/Models/Person.cs index bd818b1..6125c74 100644 --- a/Dapper.GraphQL.Test/Models/Person.cs +++ b/Dapper.GraphQL.Test/Models/Person.cs @@ -15,6 +15,7 @@ public class Person public int MergedToPersonId { get; set; } public IList Phones { get; set; } public Person Supervisor { get; set; } + public DateTime CreateDate { get; set; } public Person() { diff --git a/Dapper.GraphQL.Test/QueryBuilders/PersonQueryBuilder.cs b/Dapper.GraphQL.Test/QueryBuilders/PersonQueryBuilder.cs index dfab3a1..ac1cefc 100644 --- a/Dapper.GraphQL.Test/QueryBuilders/PersonQueryBuilder.cs +++ b/Dapper.GraphQL.Test/QueryBuilders/PersonQueryBuilder.cs @@ -32,7 +32,12 @@ public SqlQueryContext Build(SqlQueryContext query, IHaveSelectionSet context, s query.Select($"{alias}.Id", $"{alias}.MergedToPersonId"); query.SplitOn("Id"); - var fields = context.GetSelectedFields(); + var fields = QueryBuilderHelper.CollectFields(context.SelectionSet); + + if (QueryBuilderHelper.IsConnection(context.SelectionSet)) + { + query.Select($"{alias}.CreateDate"); + } if (fields.ContainsKey("firstName")) { diff --git a/Dapper.GraphQL.Test/QueryBuilders/QuerybuilderHelper.cs b/Dapper.GraphQL.Test/QueryBuilders/QuerybuilderHelper.cs new file mode 100644 index 0000000..81b2423 --- /dev/null +++ b/Dapper.GraphQL.Test/QueryBuilders/QuerybuilderHelper.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using GraphQL.Language.AST; +using System.Linq; +using GraphQL; + + +namespace Dapper.GraphQL.Test.QueryBuilders +{ + public static class QueryBuilderHelper + { + public static Dictionary CollectFields(SelectionSet selectionSet) + { + return CollectFields(selectionSet, Fields.Empty()); + } + + private static Fields CollectFields(SelectionSet selectionSet, Fields fields) + { + List skipList = new List { "edges", "node", "cursor" }; + selectionSet?.Selections.Apply(selection => + { + if (selection is Field field) + { + if (!skipList.Exists(name => name.ToLower().Equals(field.Name))) + { + fields.Add(field); + } + + CollectFields(field.SelectionSet, fields); + } + }); + + return fields; + } + + public static bool IsConnection(SelectionSet selectionSet) + { + return IsConnection(selectionSet, new Dictionary()); + } + + public static bool IsConnection(SelectionSet selectionSet, Dictionary fields) + { + selectionSet?.Selections.Apply(selection => + { + if (selection is Field field) + { + if (field.Name == "edges") + { + fields.Add(field.Name, field); + } + + IsConnection(field.SelectionSet, fields); + } + }); + + return fields.Any(); + } + } +} diff --git a/Dapper.GraphQL.Test/Repositories/PersonRepository.cs b/Dapper.GraphQL.Test/Repositories/PersonRepository.cs new file mode 100644 index 0000000..3417a26 --- /dev/null +++ b/Dapper.GraphQL.Test/Repositories/PersonRepository.cs @@ -0,0 +1,205 @@ +using System; +using System.Data; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GraphQL.Builders; +using Dapper.GraphQL.Test.Models; +using Dapper.GraphQL.Test.EntityMappers; +using Microsoft.Extensions.DependencyInjection; + + +namespace Dapper.GraphQL.Test.Repositories +{ + public class PersonRepository : IPersonRepository + { + private readonly IQueryBuilder _personQueryBuilder; + private PersonEntityMapper personMapper = new PersonEntityMapper(); + private string pAlias = "Person"; + private IServiceProvider _serviceProvider; + + public PersonRepository(IQueryBuilder personQueryBuilder, IServiceProvider serviceProvider) + { + _personQueryBuilder = personQueryBuilder; + _serviceProvider = serviceProvider; + } + + public ResolveConnectionContext Context { get; set; } + + public Task GetTotalCount(CancellationToken cancellationToken) + { + var query = this.GetQuery(Context, _personQueryBuilder); + + try + { + using (var connection = _serviceProvider.GetRequiredService()) + { + var results = query + .Execute(connection, Context.FieldAst, personMapper) + .Distinct() + .Count(); + + return Task.FromResult(results); + } + } + catch (Exception ex) + { + // log + } + + return Task.FromResult(0); + } + + public Task> GetPeople(int? first, + DateTime? createdAfter, + CancellationToken cancellationToken) + { + string sWhere = createdAfter != null ? ($"{pAlias}.CreateDate > '{createdAfter}'") : ""; + var query = this.GetQuery(Context, _personQueryBuilder, sWhere); + + try + { + using (var connection = _serviceProvider.GetRequiredService()) + { + List results = query + .Execute(connection, Context.FieldAst, personMapper) + .Distinct() + .If(first.HasValue, x => x.Take(first.Value)) + .ToList(); + + return Task.FromResult(results); + } + } + catch (Exception ex) + { + // log + } + + return Task.FromResult>(null); + } + + public Task> GetPeopleReversed(int? last, + DateTime? createdBefore, + CancellationToken cancellationToken) + { + string sWhere = createdBefore != null ? ($"{pAlias}.CreateDate < @{createdBefore}") : ""; + var query = this.GetQuery(Context, _personQueryBuilder, sWhere); + + try + { + using (var connection = _serviceProvider.GetRequiredService()) + { + List results = query + .Execute(connection, Context.FieldAst, personMapper) + .Distinct() + .If(last.HasValue, x => x.TakeLast(last.Value)) + .ToList(); + + return Task.FromResult(results); + } + } + catch (Exception ex) + { + // log + } + + return Task.FromResult>(null); + } + + + public Task GetHasNextPage(int? first, + DateTime? createdAfter, + CancellationToken cancellationToken) + { + string sWhere = createdAfter != null ? ($"{pAlias}.CreateDate > '{createdAfter}'") : ""; + var query = this.GetQuery(Context, _personQueryBuilder, sWhere); + + try + { + using (var connection = _serviceProvider.GetRequiredService()) + { + return Task.FromResult(query + .Execute(connection, Context.FieldAst, personMapper) + .Distinct() + .Skip(first.Value) + .Any()); + } + } + catch (Exception ex) + { + // log + } + + return Task.FromResult(false); + } + + public Task GetHasPreviousPage(int? last, + DateTime? createdBefore, + CancellationToken cancellationToken) + { + string sWhere = createdBefore != null ? ($"{pAlias}.CreateDate < '{createdBefore}'") : ""; + var query = this.GetQuery(Context, _personQueryBuilder, sWhere); + + try + { + using (var connection = _serviceProvider.GetRequiredService()) + { + return Task.FromResult(query + .Execute(connection, Context.FieldAst, personMapper) + .Distinct() + .SkipLast(last.Value) + .Any()); + } + } + catch (Exception ex) + { + // log + } + + return Task.FromResult(false); + } + } + + public interface IPersonRepository + { + ResolveConnectionContext Context { get; set; } + Task GetTotalCount(CancellationToken cancellationToken); + Task> GetPeople(int? first, DateTime? createdAfter, CancellationToken cancellationToken); + Task> GetPeopleReversed(int? last, DateTime? createdBefore, CancellationToken cancellationToken); + Task GetHasNextPage(int? first, DateTime? createdAfter, CancellationToken cancellationToken); + Task GetHasPreviousPage(int? last, DateTime? createdBefore, CancellationToken cancellationToken); + } + + public static class IPersonRepositoryExtensions + { + public static SqlQueryContext GetQuery(this IPersonRepository personRepository, + ResolveConnectionContext context, + IQueryBuilder personQueryBuilder, + string sWhere = "") + { + var alias = "Person"; + + var query = SqlBuilder + .From(alias) + .OrderBy($"{alias}.CreateDate"); + + query = !string.IsNullOrEmpty(sWhere) ? query.Where(sWhere) : query; + + return personQueryBuilder.Build(query, context.FieldAst, alias); + } + } + + public static class EnumerableExtensions + { + public static IEnumerable If(this IEnumerable enumerable, bool condition, Func, IEnumerable> action) + { + if (condition) + { + return action(enumerable); + } + + return enumerable; + } + } +} diff --git a/Dapper.GraphQL.Test/Sql/1-Create.sql b/Dapper.GraphQL.Test/Sql/1-Create.sql index eb05307..276bd8a 100644 --- a/Dapper.GraphQL.Test/Sql/1-Create.sql +++ b/Dapper.GraphQL.Test/Sql/1-Create.sql @@ -9,6 +9,7 @@ -- https://github.com/StackExchange/Dapper/issues/917 SupervisorId INTEGER, CareerCounselorId INTEGER, + CreateDate DATE, FOREIGN KEY (MergedToPersonId) REFERENCES Person(Id), FOREIGN KEY (SupervisorId) REFERENCES Person(Id), FOREIGN KEY (CareerCounselorId) REFERENCES Person(Id) diff --git a/Dapper.GraphQL.Test/Sql/2-Data.sql b/Dapper.GraphQL.Test/Sql/2-Data.sql index e45b5e5..8c2fa6d 100644 --- a/Dapper.GraphQL.Test/Sql/2-Data.sql +++ b/Dapper.GraphQL.Test/Sql/2-Data.sql @@ -1,7 +1,7 @@ -INSERT INTO Person (Id, MergedToPersonId, FirstName, LastName, SupervisorId, CareerCounselorId) VALUES (1, 1, 'Hyrum', 'Clyde', NULL, NULL); -INSERT INTO Person (Id, MergedToPersonId, FirstName, LastName, SupervisorId, CareerCounselorId) VALUES (2, 2, 'Doug', 'Day', NULL, NULL); -INSERT INTO Person (Id, MergedToPersonId, FirstName, LastName, SupervisorId, CareerCounselorId) VALUES (3, 3, 'Kevin', 'Russon', 1, 2); -INSERT INTO Person (Id, MergedToPersonId, FirstName, LastName, SupervisorId, CareerCounselorId) VALUES (4, 4, 'Douglas', 'Day', NULL, 1); +INSERT INTO Person (Id, MergedToPersonId, FirstName, LastName, SupervisorId, CareerCounselorId, CreateDate) VALUES (1, 1, 'Hyrum', 'Clyde', NULL, NULL, '2019-01-01'); +INSERT INTO Person (Id, MergedToPersonId, FirstName, LastName, SupervisorId, CareerCounselorId, CreateDate) VALUES (2, 2, 'Doug', 'Day', NULL, NULL, '2019-01-02'); +INSERT INTO Person (Id, MergedToPersonId, FirstName, LastName, SupervisorId, CareerCounselorId, CreateDate) VALUES (3, 3, 'Kevin', 'Russon', 1, 2, '2019-01-03'); +INSERT INTO Person (Id, MergedToPersonId, FirstName, LastName, SupervisorId, CareerCounselorId, CreateDate) VALUES (4, 4, 'Douglas', 'Day', NULL, 1, '2019-01-04'); -- Update the identity value SELECT setval(pg_get_serial_sequence('person', 'id'), (SELECT MAX(Id) FROM Person)); diff --git a/Dapper.GraphQL.Test/TestFixture.cs b/Dapper.GraphQL.Test/TestFixture.cs index 36897fe..c995123 100644 --- a/Dapper.GraphQL.Test/TestFixture.cs +++ b/Dapper.GraphQL.Test/TestFixture.cs @@ -1,11 +1,13 @@ using Dapper.GraphQL.Test.GraphQL; using Dapper.GraphQL.Test.Models; using Dapper.GraphQL.Test.QueryBuilders; +using Dapper.GraphQL.Test.Repositories; using DbUp; using GraphQL; using GraphQL.Execution; using GraphQL.Http; using GraphQL.Language.AST; +using GraphQL.Types.Relay; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json.Linq; using Npgsql; @@ -16,6 +18,7 @@ using System.Reflection; using System.Threading.Tasks; + namespace Dapper.GraphQL.Test { public class TestFixture : IDisposable @@ -187,6 +190,13 @@ private void SetupDapperGraphQL(IServiceCollection serviceCollection) options.AddQueryBuilder(); }); + serviceCollection.AddSingleton(); + + // Support for GraphQL paging + serviceCollection.AddTransient(typeof(ConnectionType<>)); + serviceCollection.AddTransient(typeof(EdgeType<>)); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(serviceProvider => GetDbConnection()); } }