Skip to content

Instantly share code, notes, and snippets.

@tom2strobl
Created March 16, 2021 10:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tom2strobl/dfcea7316c196fa153ec94c3bff4dc0c to your computer and use it in GitHub Desktop.
Save tom2strobl/dfcea7316c196fa153ec94c3bff4dc0c to your computer and use it in GitHub Desktop.
Helper factory to create update functions for gql-generated hasura queries (WIP)
/* 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