Created
March 16, 2021 10:42
-
-
Save tom2strobl/dfcea7316c196fa153ec94c3bff4dc0c to your computer and use it in GitHub Desktop.
Helper factory to create update functions for gql-generated hasura queries (WIP)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable no-else-return */ | |
// disabling no-else-return for readability | |
import { Cache, Data, Variables } from '@urql/exchange-graphcache' | |
import { | |
ArgumentNode, | |
DocumentNode, | |
EnumValueNode, | |
FieldNode, | |
ObjectValueNode, | |
OperationDefinitionNode, | |
ValueNode | |
} from 'graphql' | |
// https://github.com/tom2strobl/order-by-sort/ | |
import { orderBySort, OrderByEntry } from 'order-by-sort' | |
/* | |
Example usage (omitting TaskFragment for brevity): | |
const ListOpenTasksDocument = gql` | |
query ListOpenTasks($workspace: Int!) { | |
task( | |
where: { workspace_id: { _eq: $workspace }, is_done: { _eq: false }, deletion_date: { _is_null: true } } | |
order_by: { order: asc_nulls_last, updated_at: desc } | |
) { | |
...TaskFragment | |
} | |
} | |
` | |
const updates: Partial<UpdatesConfig> = { | |
Mutation: { | |
update_task_by_pk: (result, untypedArgs, cache) => { | |
const workspaceId = cache.resolve({ __typename: 'task', id: args.pk_columns.id }, 'workspace_id') | |
// this will update the following query to add/remove depending on workspace_id, is_done and deletion_date are met/unmet | |
updateQuery({ | |
query: ListOpenTasksDocument, // this is a graphql-codegen query of "tasks" | |
variables: { workspace: workspaceId } | |
}) | |
} | |
} | |
} | |
*/ | |
// ######################## | |
// DISCLAIMER: all of this requires you to always have an id present on your entities | |
// ######################## | |
// TODO: support all where operators | |
// TODO: support distinct_on as well | |
// TODO: try to support pagination Expressions as well (limit, offset) | |
export interface ScalarWithId { | |
id: number | |
[key: string]: unknown | |
} | |
export type WhereOperator = | |
// these are done | |
| '_eq' | |
| '_lte' | |
| '_is_null' | |
| '_gte' | |
// these are still to implement | |
| '_gt' | |
| '_ilike' | |
| '_in' | |
| '_like' | |
| '_lt' | |
| '_neq' | |
| '_nilike' | |
| '_nin' | |
| '_nlike' | |
| '_nsimilar' | |
| '_similar' | |
| '_contained_in' | |
| '_contains' | |
| '_has_key' | |
| '_has_keys_all' | |
| '_has_keys_any' | |
export interface WhereCondition { | |
field: string | |
value: WhereConditionValue[] | |
} | |
export interface WhereConditionValue { | |
operator: WhereOperator | |
value: ValueNode | |
} | |
export type OrderOperator = | |
| 'asc' | |
| 'asc_nulls_first' | |
| 'asc_nulls_last' | |
| 'desc' | |
| 'desc_nulls_first' | |
| 'desc_nulls_last' | |
export interface UpdateQueryFactoryProps<T> { | |
keyedResult: T | null | |
args: Variables | |
cache: Cache | |
typename: string | |
} | |
export interface UpdateQueryProps { | |
query: string | DocumentNode | |
variables: Variables | |
} | |
/** | |
* Extracts Arguments from a DocumentNode | |
* @param documentNode A GraphQL DocumentNode, that is a product of a gql`<your query>` | |
* @returns ArgumentNode[] Array of GraphlQL AST ArgumentNodes | |
*/ | |
const getArgumentsFromDefinitions = (documentNode: DocumentNode) => { | |
// TODO: this whole selection process, especially selections[0] is super smelly, needs some love | |
const operation = documentNode?.definitions?.find((d) => d.kind === 'OperationDefinition') as OperationDefinitionNode | |
const fieldNode = operation.selectionSet?.selections[0] as FieldNode | |
if (!fieldNode.arguments) { | |
throw new Error('DocumentNode definition had no arguments') | |
} | |
return fieldNode.arguments as ArgumentNode[] | |
} | |
/** | |
* Walks the ArugmentNodes to extract an array of where conditions | |
* @param selectionArguments Array of GraphlQL AST ArgumentNodes (extracted by getArgumentsFromDefinitions) | |
* @returns WhereCondition[] | |
*/ | |
const getWhereFromArguments = (selectionArguments: ArgumentNode[]): WhereCondition[] => { | |
const objectValue = selectionArguments?.find((a) => a.name.value === 'where')?.value as ObjectValueNode | |
return objectValue.fields?.map((f) => { | |
const value = f.value as ObjectValueNode | |
return { | |
field: f.name.value, | |
value: value.fields.map((v) => { | |
return { | |
operator: v.name.value as WhereOperator, | |
value: v.value | |
} | |
}) | |
} | |
}) | |
} | |
/** | |
* Walks the ArugmentNodes to extract an array of ordering statements | |
* @param selectionArguments Array of GraphlQL AST ArgumentNodes (extracted by getArgumentsFromDefinitions) | |
* @returns OrderByEntry[] | |
*/ | |
const getOrderByFromArguments = (selectionArguments: ArgumentNode[]): OrderByEntry[] => { | |
const objectValue = selectionArguments?.find((a) => a.name.value === 'order_by')?.value as ObjectValueNode | |
return objectValue.fields.map((f) => { | |
const value = f.value as EnumValueNode | |
return { | |
field: f.name.value, | |
value: value.value as OrderOperator | |
} | |
}) | |
} | |
/** | |
* Factory that creates a function for said query with a ResultDocument and QueryVariables that returns a boolean | |
* whether or not the document should be present in the query | |
* @param whereArray Array of where conditions (generated by getWhereFromArguments) | |
* @returns shouldExistFn(doc, vars) | |
*/ | |
const createShouldExistFromWhereArray = (whereArray: WhereCondition[]) => { | |
return (doc: ScalarWithId, vars: Variables) => { | |
const shouldExist = whereArray.every((condition) => { | |
return condition.value.every((check) => { | |
if (check.operator === '_eq') { | |
if (check.value.kind === 'BooleanValue') { | |
return doc[condition.field] === check.value.value | |
} else if (check.value.kind === 'Variable') { | |
return doc[condition.field] === vars[check.value.name.value] | |
} else { | |
throw new Error(`Value kind ${check.value.kind} for operator ${check.operator} is not supported yet.`) | |
} | |
} else if (check.operator === '_is_null') { | |
if (check.value.kind === 'BooleanValue') { | |
const checkCounterValue = check.value.value === true ? null : false | |
return doc[condition.field] === checkCounterValue | |
} else { | |
throw new Error(`Value kind ${check.value.kind} for operator ${check.operator} is not supported yet.`) | |
} | |
} else if (check.operator === '_gte') { | |
if (check.value.kind === 'Variable') { | |
if (doc && vars) { | |
const comparableField = doc[condition.field] as string | number | |
const comparableVar = vars[check.value.name.value] as string | number | |
return comparableField >= comparableVar | |
} | |
} else { | |
throw new Error(`Value kind ${check.value.kind} for operator ${check.operator} is not supported yet.`) | |
} | |
} else if (check.operator === '_lte') { | |
if (check.value.kind === 'Variable') { | |
const comparableField = doc[condition.field] as string | number | |
const comparableVar = vars[check.value.name.value] as string | number | |
return comparableField <= comparableVar | |
} else { | |
throw new Error(`Value kind ${check.value.kind} for operator ${check.operator} is not supported yet.`) | |
} | |
} else { | |
throw new Error(`Operator ${check.operator} is not supported yet.`) | |
} | |
return false | |
}) | |
}) | |
return shouldExist | |
} | |
} | |
/** | |
* Takes ordering conditions in form of OrderByEntry[] and returns a function to sort an array by them | |
* @param orderByArray Array of OrderByEntries | |
* @returns Function with array to sort as its only argument and a return of the array sorted | |
*/ | |
const createOrderFnByFromOrderArray = (orderByArray: OrderByEntry[]) => { | |
return (entityArray: ScalarWithId[]) => { | |
return orderBySort(entityArray, orderByArray) | |
} | |
} | |
/** | |
* Given a Query-DocumentNode, reads where and order_by arguments and generates two functions to aid with urqls `updates` | |
* on a mutation. One to decipher whether or not a document should be present in a query list and the other one to pass a | |
* list to order it like the backend would. | |
* @param documentNode A GraphQL DocumentNode, that is a product of a gql`<your query>` | |
* @returns { shouldExistFn, orderByFn } | |
*/ | |
export const getServerSideEffects = (documentNode: DocumentNode) => { | |
const args = getArgumentsFromDefinitions(documentNode) | |
const where = getWhereFromArguments(args) | |
const orderBy = getOrderByFromArguments(args) | |
return { | |
shouldExistFn: createShouldExistFromWhereArray(where), | |
orderByFn: createOrderFnByFromOrderArray(orderBy) | |
} | |
} | |
/** | |
* A factory to return a helper dunction to be used in a mutation update handler, that, given a query and variables | |
* updates said query if necessary. | |
* @param keyedResult Result/parent object passed from update function | |
* @param args Arguments object passed from update function | |
* @param cache Cache object passed from update function | |
* @param typename Name of the type where the entities are stored on the query | |
* @returns updateQueryFn | |
*/ | |
export function updateQueryFactory<EntityType extends ScalarWithId>({ | |
keyedResult, | |
args, | |
cache, | |
typename | |
}: UpdateQueryFactoryProps<EntityType>): (arg0: UpdateQueryProps) => void { | |
// @ts-expect-error we know this exists, due to hasura | |
if (!args?.pk_columns?.id) { | |
throw new Error('Id primary key not present on updateQueryFactory args') | |
} | |
// primary key to compare to | |
// @ts-expect-error we know this exists, due to hasura | |
const entityId = args.pk_columns.id as number | |
return ({ query, variables }) => { | |
cache.updateQuery({ query, variables }, (data: Data | null): Data | null => { | |
// return early if the updated entity was not in the list | |
if (!data) { | |
return null | |
} | |
// we expect a result to always exist | |
if (keyedResult === null) { | |
throw new Error('Update Query is missing result!') | |
} | |
// determine where and order from DocumentNode and form shouldExist- and orderBy-functions | |
const { shouldExistFn, orderByFn } = getServerSideEffects(query as DocumentNode) | |
const queryEntities = (data[typename] || []) as ScalarWithId[] | |
let newQueryEntities = queryEntities as ScalarWithId[] | |
const shouldExist = shouldExistFn(keyedResult, variables) | |
const exists = queryEntities.find((t) => t.id === keyedResult?.id) | |
if (shouldExist && !exists) { | |
newQueryEntities = [...queryEntities, keyedResult] | |
} | |
if (!shouldExist && exists) { | |
newQueryEntities = queryEntities.filter((t) => t.id !== entityId) | |
} | |
const orderedNewQueryEntities = orderByFn(newQueryEntities) | |
const queryReturn = { ...data } | |
queryReturn[typename] = orderedNewQueryEntities | |
return queryReturn | |
}) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment