graphql-connections
Advanced tools
Comparing version 7.0.4 to 9.0.0
@@ -28,3 +28,3 @@ import { QueryBuilder as Knex } from 'knex'; | ||
addResult(result: KnexQueryResult): this; | ||
readonly pageInfo: { | ||
get pageInfo(): { | ||
hasNextPage: boolean; | ||
@@ -35,3 +35,3 @@ hasPreviousPage: boolean; | ||
}; | ||
readonly edges: { | ||
get edges(): { | ||
cursor: string; | ||
@@ -38,0 +38,0 @@ node: Node; |
@@ -6,2 +6,3 @@ 'use strict'; | ||
var graphql = require('graphql'); | ||
var utilities = require('graphql/utilities'); | ||
@@ -209,2 +210,31 @@ class CursorEncoder { | ||
const hasDotRegexp = /\./gi; | ||
// tslint:disable-next-line: cyclomatic-complexity | ||
function coerceStringValue(value) { | ||
if (value === '') { | ||
return value; | ||
} | ||
/** | ||
* Only try casting to float if there's at least one `.` | ||
* | ||
* This MUST come before parseInt because parseInt will succeed to | ||
* parse a float but it will be lossy, e.g. | ||
* parseInt('1.24242', 10) === 1 | ||
*/ | ||
if (hasDotRegexp.test(value) && !isNaN(Number(value))) { | ||
return Number(value); | ||
} | ||
if (!isNaN(Number(value))) { | ||
const parsed = Number(value); | ||
return parsed; | ||
} | ||
if (['true', 'false'].includes(value.toLowerCase())) { | ||
return value.toLowerCase() === 'true'; | ||
} | ||
if (value.toLowerCase() === 'null') { | ||
return null; | ||
} | ||
return value; | ||
} | ||
/** | ||
@@ -286,26 +316,26 @@ * KnexQueryBuilder | ||
filterArgs(filter) { | ||
const transformedFilter = this.filterTransformer(filter); | ||
if (this.useSuggestedValueLiteralTransforms && | ||
transformedFilter.operator.toLowerCase() === '=' && | ||
(transformedFilter.value === null || transformedFilter.value.toLowerCase() === 'null')) { | ||
const { field, operator, value } = this.filterTransformer(filter); | ||
if (this.useSuggestedValueLiteralTransforms) { | ||
const coercedValue = typeof value === 'string' ? coerceStringValue(value) : value; | ||
if (coercedValue === null && operator.toLowerCase() === '=') { | ||
return [ | ||
(builder) => { | ||
builder.whereNull(this.computeFilterField(field)); | ||
} | ||
]; | ||
} | ||
if (coercedValue === null && operator.toLowerCase() === '<>') { | ||
return [ | ||
(builder) => { | ||
builder.whereNotNull(this.computeFilterField(field)); | ||
} | ||
]; | ||
} | ||
return [ | ||
(builder) => { | ||
builder.whereNull(this.computeFilterField(transformedFilter.field)); | ||
} | ||
this.computeFilterField(field), | ||
this.computeFilterOperator(operator), | ||
coercedValue | ||
]; | ||
} | ||
if (this.useSuggestedValueLiteralTransforms && | ||
transformedFilter.operator.toLowerCase() === '<>' && | ||
(transformedFilter.value === null || transformedFilter.value.toLowerCase() === 'null')) { | ||
return [ | ||
(builder) => { | ||
builder.whereNotNull(this.computeFilterField(transformedFilter.field)); | ||
} | ||
]; | ||
} | ||
return [ | ||
this.computeFilterField(transformedFilter.field), | ||
this.computeFilterOperator(transformedFilter.operator), | ||
transformedFilter.value | ||
]; | ||
return [this.computeFilterField(field), this.computeFilterOperator(operator), value]; | ||
} | ||
@@ -352,6 +382,9 @@ addFilterRecursively(filter, queryBuilder) { | ||
const isFilter = (filter) => { | ||
return (!!filter && | ||
!!filter.field && | ||
!!filter.operator && | ||
!!filter.value); | ||
if (!filter) { | ||
return false; | ||
} | ||
const asIFilter = filter; | ||
return (asIFilter.field !== undefined && | ||
asIFilter.operator !== undefined && | ||
asIFilter.value !== undefined); | ||
}; | ||
@@ -617,7 +650,12 @@ | ||
description, | ||
serialize: (value) => value, | ||
serialize: (value) => String(value), | ||
parseValue: (value) => { | ||
const hasType = inputTypes.reduce((acc, t) => { | ||
const result = graphql.coerceValue(value, t); | ||
return result.errors && result.errors.length > 0 ? acc : true; | ||
try { | ||
const result = utilities.coerceInputValue(value, t); | ||
return result.errors && result.errors.length > 0 ? acc : true; | ||
} | ||
catch (error) { | ||
return acc; | ||
} | ||
}, false); | ||
@@ -629,16 +667,65 @@ if (hasType) { | ||
}, | ||
// tslint:disable-next-line: cyclomatic-complexity | ||
parseLiteral: ast => { | ||
const inputType = inputTypes.reduce((acc, type) => { | ||
const astClone = JSON.parse(JSON.stringify(ast)); | ||
try { | ||
return graphql.isValidLiteralValue(type, astClone).length === 0 ? type : acc; | ||
} | ||
catch (e) { | ||
const compoundFilterScalarType = inputTypes.find(type => type.name === 'CompoundFilterScalar'); | ||
const filterScalarType = inputTypes.find(type => type.name === 'FilterScalar'); | ||
if (!compoundFilterScalarType) { | ||
throw new Error('Invalid input type provided'); | ||
} | ||
if (!filterScalarType) { | ||
throw new Error('Invalid input type provided'); | ||
} | ||
if (ast.kind !== 'ObjectValue') { | ||
throw new Error('Invalid AST kind'); | ||
} | ||
/** | ||
* Determine if the scalar provided is a compound (or, and) | ||
* or plain filter scalar (field, operator, value) | ||
* AND it must only have one of these present in the object root. | ||
*/ | ||
const isCompoundFilterScalar = ast.fields.reduce((acc, field) => { | ||
if (acc) { | ||
return acc; | ||
} | ||
}, undefined); | ||
if (inputType) { | ||
return graphql.valueFromAST(ast, inputType); | ||
if (['or', 'and', 'not'].includes(field.name.value.toLowerCase())) { | ||
return true; | ||
} | ||
return acc; | ||
}, false) && ast.fields.length === 1; | ||
/** Determine if it is a filter scalar. */ | ||
const filterScalarFields = ast.fields | ||
.map(field => field.name.value.toLowerCase()) | ||
.reduce((acc, fieldName) => { | ||
if (fieldName === 'field') { | ||
return { | ||
...acc, | ||
hasField: true | ||
}; | ||
} | ||
if (fieldName === 'operator') { | ||
return { | ||
...acc, | ||
hasOperator: true | ||
}; | ||
} | ||
if (fieldName === 'value') { | ||
return { | ||
...acc, | ||
hasValue: true | ||
}; | ||
} | ||
return acc; | ||
}, { hasField: false, hasOperator: false, hasValue: false }); | ||
const isFilterScalar = filterScalarFields.hasField && | ||
filterScalarFields.hasOperator && | ||
filterScalarFields.hasValue; | ||
if (!isCompoundFilterScalar && !isFilterScalar) { | ||
throw generateInputTypeError(typeName, inputTypes); | ||
} | ||
throw generateInputTypeError(typeName, inputTypes); | ||
if (isCompoundFilterScalar) { | ||
return graphql.valueFromAST(ast, compoundFilterScalarType); | ||
} | ||
else { | ||
return graphql.valueFromAST(ast, filterScalarType); | ||
} | ||
} | ||
@@ -648,2 +735,28 @@ }); | ||
// tslint:disable: cyclomatic-complexity | ||
/** @see https://stackoverflow.com/a/49911974 */ | ||
// tslint:disable-next-line: variable-name | ||
const FilterValue = new graphql.GraphQLScalarType({ | ||
name: 'FilterValue', | ||
serialize: value => value, | ||
/** | ||
* `parseValue` controls what is seen by the resolver. | ||
*/ | ||
parseValue: value => value, | ||
/** | ||
* `parseLiteral` inputs the AST and returns the parsed value of the type. | ||
*/ | ||
parseLiteral(ast) { | ||
if (ast.kind === graphql.Kind.NULL) { | ||
return null; | ||
} | ||
if (ast.kind === graphql.Kind.INT || | ||
ast.kind === graphql.Kind.FLOAT || | ||
ast.kind === graphql.Kind.BOOLEAN || | ||
ast.kind === graphql.Kind.STRING) { | ||
return ast.value; | ||
} | ||
throw new Error('An invalid type was given for filter value. Must be either Int, Float, Boolean, Null, or String.'); | ||
} | ||
}); | ||
const compoundFilterScalar = new graphql.GraphQLInputObjectType({ | ||
@@ -676,3 +789,3 @@ name: 'CompoundFilterScalar', | ||
value: { | ||
type: graphql.GraphQLString | ||
type: FilterValue | ||
} | ||
@@ -804,2 +917,3 @@ }; | ||
}; | ||
// tslint:enable: cyclomatic-complexity | ||
@@ -806,0 +920,0 @@ exports.ConnectionManager = ConnectionManager; |
@@ -1,2 +0,3 @@ | ||
import { GraphQLScalarType, coerceValue, GraphQLError, isValidLiteralValue, valueFromAST, GraphQLInputObjectType, GraphQLList, GraphQLString, GraphQLInt } from 'graphql'; | ||
import { GraphQLScalarType, GraphQLError, valueFromAST, Kind, GraphQLInputObjectType, GraphQLList, GraphQLString, GraphQLInt } from 'graphql'; | ||
import { coerceInputValue } from 'graphql/utilities'; | ||
@@ -204,2 +205,31 @@ class CursorEncoder { | ||
const hasDotRegexp = /\./gi; | ||
// tslint:disable-next-line: cyclomatic-complexity | ||
function coerceStringValue(value) { | ||
if (value === '') { | ||
return value; | ||
} | ||
/** | ||
* Only try casting to float if there's at least one `.` | ||
* | ||
* This MUST come before parseInt because parseInt will succeed to | ||
* parse a float but it will be lossy, e.g. | ||
* parseInt('1.24242', 10) === 1 | ||
*/ | ||
if (hasDotRegexp.test(value) && !isNaN(Number(value))) { | ||
return Number(value); | ||
} | ||
if (!isNaN(Number(value))) { | ||
const parsed = Number(value); | ||
return parsed; | ||
} | ||
if (['true', 'false'].includes(value.toLowerCase())) { | ||
return value.toLowerCase() === 'true'; | ||
} | ||
if (value.toLowerCase() === 'null') { | ||
return null; | ||
} | ||
return value; | ||
} | ||
/** | ||
@@ -281,26 +311,26 @@ * KnexQueryBuilder | ||
filterArgs(filter) { | ||
const transformedFilter = this.filterTransformer(filter); | ||
if (this.useSuggestedValueLiteralTransforms && | ||
transformedFilter.operator.toLowerCase() === '=' && | ||
(transformedFilter.value === null || transformedFilter.value.toLowerCase() === 'null')) { | ||
const { field, operator, value } = this.filterTransformer(filter); | ||
if (this.useSuggestedValueLiteralTransforms) { | ||
const coercedValue = typeof value === 'string' ? coerceStringValue(value) : value; | ||
if (coercedValue === null && operator.toLowerCase() === '=') { | ||
return [ | ||
(builder) => { | ||
builder.whereNull(this.computeFilterField(field)); | ||
} | ||
]; | ||
} | ||
if (coercedValue === null && operator.toLowerCase() === '<>') { | ||
return [ | ||
(builder) => { | ||
builder.whereNotNull(this.computeFilterField(field)); | ||
} | ||
]; | ||
} | ||
return [ | ||
(builder) => { | ||
builder.whereNull(this.computeFilterField(transformedFilter.field)); | ||
} | ||
this.computeFilterField(field), | ||
this.computeFilterOperator(operator), | ||
coercedValue | ||
]; | ||
} | ||
if (this.useSuggestedValueLiteralTransforms && | ||
transformedFilter.operator.toLowerCase() === '<>' && | ||
(transformedFilter.value === null || transformedFilter.value.toLowerCase() === 'null')) { | ||
return [ | ||
(builder) => { | ||
builder.whereNotNull(this.computeFilterField(transformedFilter.field)); | ||
} | ||
]; | ||
} | ||
return [ | ||
this.computeFilterField(transformedFilter.field), | ||
this.computeFilterOperator(transformedFilter.operator), | ||
transformedFilter.value | ||
]; | ||
return [this.computeFilterField(field), this.computeFilterOperator(operator), value]; | ||
} | ||
@@ -347,6 +377,9 @@ addFilterRecursively(filter, queryBuilder) { | ||
const isFilter = (filter) => { | ||
return (!!filter && | ||
!!filter.field && | ||
!!filter.operator && | ||
!!filter.value); | ||
if (!filter) { | ||
return false; | ||
} | ||
const asIFilter = filter; | ||
return (asIFilter.field !== undefined && | ||
asIFilter.operator !== undefined && | ||
asIFilter.value !== undefined); | ||
}; | ||
@@ -612,7 +645,12 @@ | ||
description, | ||
serialize: (value) => value, | ||
serialize: (value) => String(value), | ||
parseValue: (value) => { | ||
const hasType = inputTypes.reduce((acc, t) => { | ||
const result = coerceValue(value, t); | ||
return result.errors && result.errors.length > 0 ? acc : true; | ||
try { | ||
const result = coerceInputValue(value, t); | ||
return result.errors && result.errors.length > 0 ? acc : true; | ||
} | ||
catch (error) { | ||
return acc; | ||
} | ||
}, false); | ||
@@ -624,16 +662,65 @@ if (hasType) { | ||
}, | ||
// tslint:disable-next-line: cyclomatic-complexity | ||
parseLiteral: ast => { | ||
const inputType = inputTypes.reduce((acc, type) => { | ||
const astClone = JSON.parse(JSON.stringify(ast)); | ||
try { | ||
return isValidLiteralValue(type, astClone).length === 0 ? type : acc; | ||
} | ||
catch (e) { | ||
const compoundFilterScalarType = inputTypes.find(type => type.name === 'CompoundFilterScalar'); | ||
const filterScalarType = inputTypes.find(type => type.name === 'FilterScalar'); | ||
if (!compoundFilterScalarType) { | ||
throw new Error('Invalid input type provided'); | ||
} | ||
if (!filterScalarType) { | ||
throw new Error('Invalid input type provided'); | ||
} | ||
if (ast.kind !== 'ObjectValue') { | ||
throw new Error('Invalid AST kind'); | ||
} | ||
/** | ||
* Determine if the scalar provided is a compound (or, and) | ||
* or plain filter scalar (field, operator, value) | ||
* AND it must only have one of these present in the object root. | ||
*/ | ||
const isCompoundFilterScalar = ast.fields.reduce((acc, field) => { | ||
if (acc) { | ||
return acc; | ||
} | ||
}, undefined); | ||
if (inputType) { | ||
return valueFromAST(ast, inputType); | ||
if (['or', 'and', 'not'].includes(field.name.value.toLowerCase())) { | ||
return true; | ||
} | ||
return acc; | ||
}, false) && ast.fields.length === 1; | ||
/** Determine if it is a filter scalar. */ | ||
const filterScalarFields = ast.fields | ||
.map(field => field.name.value.toLowerCase()) | ||
.reduce((acc, fieldName) => { | ||
if (fieldName === 'field') { | ||
return { | ||
...acc, | ||
hasField: true | ||
}; | ||
} | ||
if (fieldName === 'operator') { | ||
return { | ||
...acc, | ||
hasOperator: true | ||
}; | ||
} | ||
if (fieldName === 'value') { | ||
return { | ||
...acc, | ||
hasValue: true | ||
}; | ||
} | ||
return acc; | ||
}, { hasField: false, hasOperator: false, hasValue: false }); | ||
const isFilterScalar = filterScalarFields.hasField && | ||
filterScalarFields.hasOperator && | ||
filterScalarFields.hasValue; | ||
if (!isCompoundFilterScalar && !isFilterScalar) { | ||
throw generateInputTypeError(typeName, inputTypes); | ||
} | ||
throw generateInputTypeError(typeName, inputTypes); | ||
if (isCompoundFilterScalar) { | ||
return valueFromAST(ast, compoundFilterScalarType); | ||
} | ||
else { | ||
return valueFromAST(ast, filterScalarType); | ||
} | ||
} | ||
@@ -643,2 +730,28 @@ }); | ||
// tslint:disable: cyclomatic-complexity | ||
/** @see https://stackoverflow.com/a/49911974 */ | ||
// tslint:disable-next-line: variable-name | ||
const FilterValue = new GraphQLScalarType({ | ||
name: 'FilterValue', | ||
serialize: value => value, | ||
/** | ||
* `parseValue` controls what is seen by the resolver. | ||
*/ | ||
parseValue: value => value, | ||
/** | ||
* `parseLiteral` inputs the AST and returns the parsed value of the type. | ||
*/ | ||
parseLiteral(ast) { | ||
if (ast.kind === Kind.NULL) { | ||
return null; | ||
} | ||
if (ast.kind === Kind.INT || | ||
ast.kind === Kind.FLOAT || | ||
ast.kind === Kind.BOOLEAN || | ||
ast.kind === Kind.STRING) { | ||
return ast.value; | ||
} | ||
throw new Error('An invalid type was given for filter value. Must be either Int, Float, Boolean, Null, or String.'); | ||
} | ||
}); | ||
const compoundFilterScalar = new GraphQLInputObjectType({ | ||
@@ -671,3 +784,3 @@ name: 'CompoundFilterScalar', | ||
value: { | ||
type: GraphQLString | ||
type: FilterValue | ||
} | ||
@@ -799,3 +912,4 @@ }; | ||
}; | ||
// tslint:enable: cyclomatic-complexity | ||
export { ConnectionManager, CursorEncoder, KnexQueryBuilder as Knex, KnexMySQLFullTextQueryBuilder as KnexMySQL, QueryContext, QueryResult, gqlTypes, resolvers, typeDefs }; |
@@ -41,3 +41,3 @@ import { ICursorObj, IQueryContext, IInputArgs, IQueryContextOptions, IInputFilter } from './types'; | ||
*/ | ||
readonly isPagingBackwards: boolean; | ||
get isPagingBackwards(): boolean; | ||
/** | ||
@@ -44,0 +44,0 @@ * Sets the limit for the desired query result |
@@ -22,3 +22,3 @@ import { IQueryContext, ICursorObj, IQueryResult, IQueryResultOptions } from './types'; | ||
constructor(result: Result, queryContext: QueryContext, options?: IQueryResultOptions<ICursorObj<string>, Node>); | ||
readonly pageInfo: { | ||
get pageInfo(): { | ||
hasPreviousPage: boolean; | ||
@@ -34,12 +34,12 @@ hasNextPage: boolean; | ||
*/ | ||
readonly hasNextPage: boolean; | ||
readonly hasPrevPage: boolean; | ||
get hasNextPage(): boolean; | ||
get hasPrevPage(): boolean; | ||
/** | ||
* The first cursor in the nodes list | ||
*/ | ||
readonly startCursor: string; | ||
get startCursor(): string; | ||
/** | ||
* The last cursor in the nodes list | ||
*/ | ||
readonly endCursor: string; | ||
get endCursor(): string; | ||
/** | ||
@@ -46,0 +46,0 @@ * It is very likely the results we get back from the data store |
import { ORDER_DIRECTION } from './enums'; | ||
export interface IFilter { | ||
value: string; | ||
value: string | boolean | number | null; | ||
operator: string; | ||
@@ -5,0 +5,0 @@ field: string; |
{ | ||
"name": "graphql-connections", | ||
"version": "7.0.4", | ||
"version": "9.0.0", | ||
"description": "Build and handle Relay-like GraphQL connections using a Knex query builder", | ||
@@ -60,4 +60,3 @@ "main": "dist/index.cjs.js", | ||
"knex": "0.20.13", | ||
"graphql": "14.7.0", | ||
"graphql-tools": "^4.0.4" | ||
"graphql": "14.7.0" | ||
}, | ||
@@ -67,3 +66,2 @@ "devDependencies": { | ||
"@types/faker": "^4.1.5", | ||
"@types/graphql": "^14.0.7", | ||
"@types/jest": "^24.0.9", | ||
@@ -77,3 +75,3 @@ "@types/superagent": "^4.1.1", | ||
"koa": "^2.7.0", | ||
"mysql2": "^1.6.5", | ||
"mysql2": "2.2.5", | ||
"nodemon": "^1.18.10", | ||
@@ -84,3 +82,3 @@ "prettier": "^1.16.4", | ||
"rollup-plugin-typescript2": "^0.24.3", | ||
"sqlite3": "^4.1.1", | ||
"sqlite3": "5.0.2", | ||
"supertest": "^4.0.2", | ||
@@ -92,4 +90,4 @@ "ts-jest": "^24.0.0", | ||
"tslint-eslint-rules": "^5.4.0", | ||
"typescript": "^3.3.3333" | ||
"typescript": "4.1.3" | ||
} | ||
} |
369
README.md
# GraphQL-Connections :diamond_shape_with_a_dot_inside: | ||
- [GraphQL-Connections :diamond_shape_with_a_dot_inside:](#graphql-connections-diamondshapewithadotinside) | ||
- [GraphQL-Connections :diamond_shape_with_a_dot_inside:](#graphql-connections-diamond_shape_with_a_dot_inside) | ||
- [Install](#install) | ||
@@ -23,3 +23,3 @@ - [About](#about) | ||
- [C. specify an attributeMap](#c-specify-an-attributemap) | ||
- [2. build the query query](#2-build-the-query-query) | ||
- [2. build the query](#2-build-the-query) | ||
- [3. execute the query and build the `connection`](#3-execute-the-query-and-build-the-connection) | ||
@@ -42,4 +42,4 @@ - [Options](#options) | ||
- [Search](#search) | ||
- [Filtering on computed columns](#filtering-on-computed-columns) | ||
## Install | ||
@@ -55,3 +55,3 @@ | ||
`GraphQL-Connections` helps handle the traversal of edges between nodes. | ||
`GraphQL-Connections` helps handle the traversal of edges between nodes. | ||
@@ -72,3 +72,3 @@ In a graph, nodes connect to other nodes via edges. In the relay graphql spec, multiple edges can be represented as a single `Connection` type, which has the signature: | ||
A connection object is returned to a user when a `query request` asks for multiple child nodes connected to a parent node. | ||
A connection object is returned to a user when a `query request` asks for multiple child nodes connected to a parent node. | ||
For example, a music artist has multiple songs. In order to get all the `songs` for an `artist` you would write the graphql query request: | ||
@@ -78,35 +78,33 @@ | ||
query { | ||
artist(id: 1) { | ||
songs { | ||
songName | ||
songLength | ||
artist(id: 1) { | ||
songs { | ||
songName | ||
songLength | ||
} | ||
} | ||
} | ||
} | ||
``` | ||
However, sometimes you may only want a portion of the songs returned to you. To allow for this scenario, a `connection` is used to represent the response type of a `song`. | ||
However, sometimes you may only want a portion of the songs returned to you. To allow for this scenario, a `connection` is used to represent the response type of a `song`. | ||
```graphql | ||
query { | ||
artist(id: 1) { | ||
songs { | ||
pageInfo { | ||
hasNextPage | ||
hasPreviousPage | ||
startCursor | ||
endCursor | ||
} | ||
edges { | ||
cursor | ||
node { | ||
songName | ||
songLength | ||
artist(id: 1) { | ||
songs { | ||
pageInfo { | ||
hasNextPage | ||
hasPreviousPage | ||
startCursor | ||
endCursor | ||
} | ||
edges { | ||
cursor | ||
node { | ||
songName | ||
songLength | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
``` | ||
@@ -144,15 +142,14 @@ | ||
3. Support multiple input types that can sort, group, limit, and filter the edges in a connection | ||
## Run locally | ||
1. Run the migrations | ||
- `NODE_ENV=development npm run migrate:sqlite:latest` | ||
- `NODE_ENV=development npm run migrate:mysql:latest` | ||
2. Seed the database | ||
- `NODE_ENV=development npm run seed:sqlite:run` | ||
- `NODE_ENV=development npm run seed:mysql:run` | ||
3. Run the dev server | ||
- `npm run dev:sqlite` (search is not supported) | ||
- `npm run dev:mysql` (search IS supported :)) | ||
1. Run the migrations | ||
- `NODE_ENV=development npm run migrate:sqlite:latest` | ||
- `NODE_ENV=development npm run migrate:mysql:latest` | ||
2. Seed the database | ||
- `NODE_ENV=development npm run seed:sqlite:run` | ||
- `NODE_ENV=development npm run seed:mysql:run` | ||
3. Run the dev server | ||
- `npm run dev:sqlite` (search is not supported) | ||
- `npm run dev:mysql` (search IS supported :)) | ||
4. Visit the GraphQL playground [http://localhost:4000/graphql](http://localhost:4000/graphql) | ||
@@ -163,28 +160,30 @@ 5. Run some queries! | ||
query { | ||
users( | ||
first: 100, | ||
orderBy: "haircolor", | ||
filter: { and: [ | ||
{field: "id", operator: ">", value: "19990"}, | ||
{field: "age", operator: "<", value: "90"}, | ||
]}, | ||
search: "random search term" | ||
) { | ||
pageInfo { | ||
hasNextPage | ||
hasPreviousPage | ||
startCursor | ||
endCursor | ||
users( | ||
first: 100 | ||
orderBy: "haircolor" | ||
filter: { | ||
and: [ | ||
{field: "id", operator: ">", value: "19990"} | ||
{field: "age", operator: "<", value: "90"} | ||
] | ||
} | ||
search: "random search term" | ||
) { | ||
pageInfo { | ||
hasNextPage | ||
hasPreviousPage | ||
startCursor | ||
endCursor | ||
} | ||
edges { | ||
cursor | ||
node { | ||
username | ||
lastname | ||
id | ||
haircolor | ||
bio | ||
} | ||
} | ||
} | ||
edges { | ||
cursor | ||
node { | ||
username | ||
lastname | ||
id | ||
haircolor | ||
bio | ||
} | ||
} | ||
} | ||
} | ||
@@ -195,21 +194,18 @@ ``` | ||
query { | ||
users( | ||
first: 10, | ||
after: "eyJmaXJzdFJlc3VsdElkIjoxOTk5MiwibGFzdFJlc3VsdE" | ||
) { | ||
pageInfo { | ||
hasNextPage | ||
hasPreviousPage | ||
users(first: 10, after: "eyJmaXJzdFJlc3VsdElkIjoxOTk5MiwibGFzdFJlc3VsdE") { | ||
pageInfo { | ||
hasNextPage | ||
hasPreviousPage | ||
} | ||
edges { | ||
cursor | ||
node { | ||
username | ||
lastname | ||
id | ||
haircolor | ||
bio | ||
} | ||
} | ||
} | ||
edges { | ||
cursor | ||
node { | ||
username | ||
lastname | ||
id | ||
haircolor | ||
bio | ||
} | ||
} | ||
} | ||
} | ||
@@ -228,12 +224,12 @@ ``` | ||
type Query { | ||
users( | ||
first: First | ||
last: Last | ||
orderBy: OrderBy | ||
orderDir: OrderDir | ||
before: Before | ||
after: After | ||
filter: Filter | ||
search: Search | ||
): QueryUsersConnection | ||
users( | ||
first: First | ||
last: Last | ||
orderBy: OrderBy | ||
orderDir: OrderDir | ||
before: Before | ||
after: After | ||
filter: Filter | ||
search: Search | ||
): QueryUsersConnection | ||
} | ||
@@ -346,5 +342,3 @@ ``` | ||
// create a new node connection instance | ||
const nodeConnection = new ConnectionManager< | ||
INode, | ||
>(inputArgs, attributeMap); | ||
const nodeConnection = new ConnectionManager<INode>(inputArgs, attributeMap); | ||
@@ -355,3 +349,3 @@ // apply the connection to the queryBuilder | ||
// run the query | ||
const result = await appliedQuery.select() | ||
const result = await appliedQuery.select(); | ||
@@ -366,3 +360,3 @@ // add the result to the nodeConnection | ||
}; | ||
} | ||
}; | ||
``` | ||
@@ -398,3 +392,3 @@ | ||
addResult: (KnexQueryResult) => void | ||
pageInfo?: IPageInfo | ||
pageInfo?: IPageInfo | ||
edges?: IEdge[] | ||
@@ -408,5 +402,5 @@ | ||
} | ||
interface IEdge { | ||
cursor: string; | ||
cursor: string; | ||
node: INode | ||
@@ -423,6 +417,6 @@ } | ||
To use a `nodeConnection` you will have to: | ||
1. initialize the nodeConnection | ||
2. build the connection query | ||
3. build the connection from the executed query | ||
1. initialize the nodeConnection | ||
2. build the connection query | ||
3. build the connection from the executed query | ||
@@ -437,3 +431,3 @@ #### 1. Initialize the `nodeConnection` | ||
For example, in this case we create an `IUserNode` | ||
For example, in this case we create an `IUserNode` | ||
@@ -458,3 +452,3 @@ ```typescript | ||
orderBy?: string; // order by a node field | ||
orderDir: 'asc' | 'desc' | ||
orderDir: 'asc' | 'desc'; | ||
filter?: IOperationFilter; | ||
@@ -482,31 +476,37 @@ } | ||
query { | ||
users(filter: { | ||
or: [ | ||
{ field: "age", operator: "=", value: "40"}, | ||
{ field: "age", operator: "<", value: "30"}, | ||
{ and: [ | ||
{ field: "haircolor", operator: "=", value: "blue"}, | ||
{ field: "age", operator: "=", value: "70"}, | ||
{ or: [ | ||
{ field: "username", operator: "=", value: "Ellie86"}, | ||
{ field: "username", operator: "=", value: "Euna_Oberbrunner"}, | ||
]} | ||
]}, | ||
], | ||
}) { | ||
pageInfo { | ||
hasNextPage | ||
hasPreviousPage | ||
users( | ||
filter: { | ||
or: [ | ||
{field: "age", operator: "=", value: "40"} | ||
{field: "age", operator: "<", value: "30"} | ||
{ | ||
and: [ | ||
{field: "haircolor", operator: "=", value: "blue"} | ||
{field: "age", operator: "=", value: "70"} | ||
{ | ||
or: [ | ||
{field: "username", operator: "=", value: "Ellie86"} | ||
{field: "username", operator: "=", value: "Euna_Oberbrunner"} | ||
] | ||
} | ||
] | ||
} | ||
] | ||
} | ||
) { | ||
pageInfo { | ||
hasNextPage | ||
hasPreviousPage | ||
} | ||
edges { | ||
cursor | ||
node { | ||
id | ||
age | ||
haircolor | ||
lastname | ||
username | ||
} | ||
} | ||
} | ||
edges { | ||
cursor | ||
node { | ||
id | ||
age | ||
haircolor | ||
lastname | ||
username | ||
} | ||
} | ||
} | ||
} | ||
@@ -518,7 +518,7 @@ ``` | ||
```sql | ||
SELECT * | ||
FROM `mock` | ||
WHERE `age` = '40' OR `age` < '30' OR (`haircolor` = 'blue' AND `age` = '70' AND (`username` = 'Ellie86' OR `username` = 'Euna_Oberbrunner')) | ||
ORDER BY `id` | ||
ASC | ||
SELECT * | ||
FROM `mock` | ||
WHERE `age` = '40' OR `age` < '30' OR (`haircolor` = 'blue' AND `age` = '70' AND (`username` = 'Ellie86' OR `username` = 'Euna_Oberbrunner')) | ||
ORDER BY `id` | ||
ASC | ||
LIMIT 1001 | ||
@@ -533,5 +533,5 @@ ``` | ||
ex: | ||
ex: | ||
```typescript | ||
```typescript | ||
const attributeMap = { | ||
@@ -543,3 +543,3 @@ id: 'id', | ||
#### 2. build the query query | ||
#### 2. build the query | ||
@@ -606,3 +606,3 @@ ```typescript | ||
```typescript | ||
const options = { | ||
const options = { | ||
contextOptions: { ... } | ||
@@ -622,3 +622,3 @@ resultOptions: { ... } | ||
```typescript | ||
number | ||
number; | ||
``` | ||
@@ -642,3 +642,3 @@ | ||
```typescript | ||
type filterTransformer = (filter: IFilter) => IFilter | ||
type filterTransformer = (filter: IFilter) => IFilter; | ||
``` | ||
@@ -654,3 +654,3 @@ | ||
interface IFilterMap { | ||
[operator: string]: string | ||
[operator: string]: string; | ||
} | ||
@@ -689,3 +689,3 @@ ``` | ||
```typescript | ||
searchColumns: string | ||
searchColumns: string; | ||
``` | ||
@@ -733,8 +733,8 @@ | ||
Internally, the `ConnectionManager` manages the orchestration of the `QueryContext`, `QueryBuilder`, and `QueryResult`. | ||
Internally, the `ConnectionManager` manages the orchestration of the `QueryContext`, `QueryBuilder`, and `QueryResult`. | ||
The orchestration follows the steps: | ||
1. The `QueryContext` extracts the connection attributes from the input connection arguments. | ||
2. The `QueryBuilder` (or `KnexQueryBuilder` in the default case) consumes the connection attributes and builds a query. The query is submitted to the database by the user and the result is sent to the `QueryResult`. | ||
1. The `QueryContext` extracts the connection attributes from the input connection arguments. | ||
2. The `QueryBuilder` (or `KnexQueryBuilder` in the default case) consumes the connection attributes and builds a query. The query is submitted to the database by the user and the result is sent to the `QueryResult`. | ||
3. The `QueryResult` uses the result to build the `edges` (which contain a `cursor` and `node`) and extract the `page info`. | ||
@@ -744,3 +744,3 @@ | ||
![Image of Architecture](https://docs.google.com/drawings/d/e/2PACX-1vRwtC2UiFwLXFDbmBNoq_6bD1YTyACV49SWHxfj2ce_K5T_XEZYlgGP7ntbcskoMVWqXp5C2Uj-K7Jj/pub?w=1163&h=719) | ||
![Image of Architecture](https://docs.google.com/drawings/d/e/2PACX-1vRwtC2UiFwLXFDbmBNoq_6bD1YTyACV49SWHxfj2ce_K5T_XEZYlgGP7ntbcskoMVWqXp5C2Uj-K7Jj/pub?w=1163&h=719) | ||
@@ -783,1 +783,72 @@ ## Search | ||
``` | ||
## Filtering on computed columns | ||
Sometimes you may compute a field that depends on some other table than the one being paged over. In this case, you can use a derived table as your `from` and alias it to the primary table. In the following example we create a derived alias of "segment", the table we are paging over, to allow filtering and sorting on "popularity", a field computed on the aggregation of values from another table. | ||
```ts | ||
import {Resolver} from 'types/resolver'; | ||
import {ISegmentNode} from 'types/graphql'; | ||
import {segment as segmentTransformer} from 'transformers/sql_to_graphql'; | ||
import {IQueryResult, IInputArgs, ConnectionManager} from 'graphql-connections'; | ||
const attributeMap = { | ||
createdAt: 'created_at', | ||
updatedAt: 'updated_at', | ||
name: 'name', | ||
explorer: 'explorer_id', | ||
popularity: 'popularity' | ||
}; | ||
const segments: Resolver<Promise<IQueryResult<ISegmentNode>>, undefined, IInputArgs> = async ( | ||
_, | ||
input: IInputArgs, | ||
ctx | ||
) => { | ||
const {connection} = ctx.clients.sqlClient; | ||
const queryBuilder = connection.queryBuilder().from( | ||
connection.raw( | ||
`( | ||
select | ||
segment.*, | ||
coalesce(sum(user_segment.usage_count), 0) as popularity | ||
from segment | ||
left join user_segment on user_segment.segment_id = segment.id | ||
group by | ||
segment.id | ||
) as segment` | ||
) | ||
); | ||
const nodeConnection = new ConnectionManager<ISegmentNode>(input, attributeMap, { | ||
builderOptions: { | ||
filterTransformer(filter) { | ||
if (filter.field === 'popularity') { | ||
return { | ||
field: 'popularity', | ||
operator: filter.operator, | ||
value: Number(filter.value) as any | ||
}; | ||
} | ||
return filter; | ||
} | ||
}, | ||
resultOptions: { | ||
nodeTransformer: segmentTransformer | ||
} | ||
}); | ||
const query = nodeConnection.createQuery(queryBuilder).select('*'); | ||
nodeConnection.addResult(await query); | ||
return { | ||
pageInfo: nodeConnection.pageInfo, | ||
edges: nodeConnection.edges | ||
}; | ||
}; | ||
export default segments; | ||
``` |
112880
2
24
19
2157
826
- Removedgraphql-tools@^4.0.4
- Removed@wry/equality@0.1.11(transitive)
- Removedapollo-link@1.2.14(transitive)
- Removedapollo-utilities@1.3.4(transitive)
- Removeddeprecated-decorator@0.1.6(transitive)
- Removedfast-json-stable-stringify@2.1.0(transitive)
- Removedgraphql-tools@4.0.8(transitive)
- Removedts-invariant@0.4.4(transitive)
- Removedtslib@1.14.1(transitive)
- Removeduuid@3.4.0(transitive)
- Removedzen-observable@0.8.15(transitive)
- Removedzen-observable-ts@0.8.21(transitive)