Replies: 2 comments 25 replies
-
Alright. This might be the most fun I have ever had with typescript and generalization. For the purpose of implementation of cursor-based pagination, the basic redundancy in the code stems from three major places:
I have devised a general enough solution for the first problem that would allow the other two to be mitigated easily. We can start by extending the Here is what the call signature of the function would look like: type GetNodeFromResultFnType<T, U> = {
(result: U): T;
};
type GetCursorFromResultFnType<U> = {
(result: U): string;
};
type AfterFilterQueryType = {
[key: string]: {
$gte: string;
};
};
type BeforeFilterQueryType = {
[key: string]: {
$lte: string;
};
};
/*
This is a function which generates a GraphQL cursor pagination resolver based on the requirements of the client.
The function takes the following arguments:
A. TYPE PARAMETERS
1. T: Refers the type of the node that the connection and it's edges would refer.
Example values include `Interface_User`, `Interface_Organization`, `Interface_Event`, `Interface_Post`.
2. U: Regers to the type of interface that is implemented by the model that you want to query.
For example, if you want to query the TagUser Model, then you would send Interface_UserTag for this type parameter
B. DATA PARAMETERS
1. args: These are the actual args that are send by the client in the request.
2. databaseModel: Refers to the actual database model that you want to query.
3. fieldToSortBy: Refers to the field on the Type U that you want to act as the cursor (ansd thus would be used
for filtering and sorting or results). Can use the . notation to access nested values
4. filterQuery: Refers to the filter object that you want to pass to the .find() query which quering the databaseModel
For example, User, Tag, Post, Organization etc.
5. fieldsToPopulate: A string that lists all the fields that you want to be populated in the model.
It is an optional parameter and can be skipped.
6. getNodeFromResult: Describes a transformation function that given an object of type U, would convert it to the desired object of type T. This would mostly include mapping to some specific field of the fetched object.
7. getCursorFromResult: Describes a transformation function that is used to generate the cursor from a particular fetched
result object of type U. This would mostly be a mapping function. It must be noted that this field should exactly match
the field provided in the fieldToSortBy argument to get sensible results.
It is important to know that the function would would sequentially in the following manner:
1. Fetch all the documents specified by your filter query.
2. Sort them by the field provided.
3. Populate the fields provided.
4. Run the functions getNodeFromResult and getCursorFromResult on each of the fetched objects from the database.
The function returns a promise which would resolve to the desired connection object (of the type Interface_Connection<T>).
*/
export async function createGraphQLConnection<T, U>(
args: CursorPaginationArgsType,
databaseModel: Model<U>,
fieldToSortBy: string,
filterQuery: FilterQuery<U>,
fieldsToPopulate: string | null,
getNodeFromResult: GetNodeFromResultFnType<T, U>,
getCursorFromResult: GetCursorFromResultFnType<U>
): Promise<Interface_Connection<T>> {
// Actual implementation of the function
} I have tried to document the definitions and the use cases of the arguments as much as I can in the comments itself, but a few examples would go a long way in clarifying the use case!
export const usersAssignedTo: UserTagResolvers["usersAssignedTo"] = async (
parent,
args
) => {
return await createGraphQLConnection<Interface_User, Interface_TagUser>(
args,
TagUser,
"_id",
{
tagId: parent._id,
},
"userId",
(result) => result.userId,
(result) => result._id.toString()
);
}; Here,
export const usersAssignedTo: UserTagResolvers["usersAssignedTo"] = async (
parent,
args
) => {
return await createGraphQLConnection<Interface_User, Interface_TagUser>(
args,
TagUser,
"userId",
{
tagId: parent._id,
},
"userId",
(result) => result.userId,
(result) => result.userId._id.toString()
);
}; Here,
export const childTags: UserTagResolvers["childTags"] = async (
parent,
args
) => {
return await createGraphQLConnection<
Interface_OrganizationTagUser,
Interface_OrganizationTagUser
>(
args,
OrganizationTagUser,
"_id",
{
parentTagId: parent._id,
},
null,
(result) => result,
(result) => result._id.toString()
);
}; Here, As evident from the three examples, the presence of the said arguments makes the whole approach of generating pagination flexible enough so that it can be extended to all types of models and interfaces. |
Beta Was this translation helpful? Give feedback.
-
@xoldyckk @EshaanAgg I think I should explain this comment you linked for Error Handling in more detail using graphql schema within the talawa-api. Please do read this approach Let us look at the type definitions here already present in talawa-api. input UserInput {
firstName: String!
lastName: String!
email: EmailAddress!
password: String!
appLanguageCode: String
organizationUserBelongsToId: ID
}
type AuthData {
user: User!
accessToken: String!
refreshToken: String!
androidFirebaseOptions: AndroidFirebaseOptions!
iosFirebaseOptions: IOSFirebaseOptions!
}
// Here I went ahead and made the EmailAddress Scalar nullable the rest is the same, this is important for use later
type User {
tokenVersion: Int!
_id: ID!
firstName: String!
lastName: String!
email: EmailAddress
userType: String,
appLanguageCode: String!
createdOrganizations: [Organization]
joinedOrganizations: [Organization]
createdEvents: [Event]
registeredEvents: [Event]
eventAdmin: [Event]
adminFor: [Organization]
membershipRequests: [MembershipRequest]
organizationsBlockedBy: [Organization]
image: String
organizationUserBelongsTo: Organization
pluginCreationAllowed: Boolean
adminApproved: Boolean
createdAt: DateTime
tagsAssignedWith(
after: String
before: String
first: PositiveInt
last: PositiveInt
organizationId: ID
): UserTagsConnection
}
// now begins the Error unions types, we can consume many error types in this
union SignUpError = EmailTaken | PasswordTooShort | UserError
type EmailTaken implements UserError {
message: String!
path: String!
suggestion: String!
}
type PasswordTooShort implements UserError {
message: String!
path: String!
minimumLength: Int!
}
interface UserError {
message: String!
path: String!
}
// Here is the return type of signup mutation notice how the signUpData is nullable here, well that is optional.
type SignUpResult {
signUpData : AuthData ,
signUpErrors : [SignUpError!]
}
type Mutation {
signUp(data: UserInput!, file: String): SignUpResult!
} Now let us look at the root resolver part for the signUp mutation in terms of Pseudo Code, In this example itself I am taking care of all three use cases which were the main problem of my stage 5 approach->
const resolvers = {
Mutation: {
signUp: async (parent, args, context) => {
//Ofc I am not showing all the details of the resolver but just the general approach of how this would work.
userObj = {} ,
signUpErrors = []
If(CHECK DUPLICATION OF EMAIL ) {
SET THE EMAIL FIELD OF USEROBJ TO BE RETURNED AS NULL ;
signUpErrors.push({
__typename: "EmailTaken" ,
message: "Email is already taken"
path: "UserInput.email"
suggestion: `Try to provide a unique mail or make sure you have not created an account
already`
})
}
If (CHECK args.PASSWORD LENGTH) {
userErrors.push({
__typename: "EmailTaken" ,
message: "Email is already taken"
path: "UserInput.email"
})
}
// Approach Allowing Atomicity in sending Errors by returning from the resolver in certain Errors
If (CERTAIN CHECK WHERE WE WOULD NEED TO RETURN ONLY THAT ERROR IN THE
signUpErrors ARRAY AND NOT SEND ANY signUpData) {
return {
signUpData: null,
signUpErrors: [{
__typename:"UserError" ,
message: "message" ,
path: "some path or somthing, idk"
}]
}
}
// Here we will be returning multiple errors in the form on an array of signUpErrors
if (signUpErrors) {
return {
signUpData: {
user : userObj,
.... other AuthData fields
} ,
signUpErrors
}
}
EVERYTHING IS OKAY, CREATE THE USER IN DB,
return {
signUpData: {
user : CreatedUserObj,
.... other AuthData fields
} ,
signUpErrors
}
}
}
} Now lets look at the client side part , mutation {
createUser(input: {}) {
signUpData { ... some fields }
signUpErrors {
# Specific cases
... on UserNameTaken {
message
path
suggestion
}
# Interface contract
... on UserError {
message
path
}
}
}
} Please do tell me what do you two think about this approach, it is based on this link |
Beta Was this translation helpful? Give feedback.
-
The graphql arguments need to be parsed/validated/transformed to be used in resolvers. During the validation step, errors will be encountered which will need to be relayed back to clients using type-safe errors within schema approach. So, something like utility functions. Take a reference from these discussions link1, link2.
The ideas need to incorporate arguments for all kinds of resolvers, whether they return a scalar field, a complex object, or the graphql connections. The ideas need to consider code resusability, correctness, and flexible result communication, and of course type-safety in mind.
A good starting point would be discussion on handling basic graphql connection arguments which are common to all graphql connection resolvers. The common arguments are
first
,after
,last
andbefore
. Though additional arguments can be added to connection resolvers for purposes of sorting/filtering, but sorting/filtering will usually be different for different resolvers, so each connection resolver will probably need seperate method for parsing/validating/filtering sort/filter arguments.Beta Was this translation helpful? Give feedback.
All reactions