diff --git a/README.md b/README.md index 258ef4b..cd0197a 100644 --- a/README.md +++ b/README.md @@ -192,3 +192,57 @@ projects lint script: ```sh yarn run lint ``` + +## GQL query filtering + +While GQL does not define how filtering should work, it provides room for arguments to passed into queries. `qontract-server` offers a generic `filter` argument, that can be used to filter the resultset of a query. + +```gql +query MyQuery($filter: JSON) { + clusters: clusters_v1(filter: $filter) { + ... + } +} +``` + +The filter argument is a JSON document that can have the following content. + +### Field equality predicate + +To filter on an fields value, use the following filter object syntax + +```json +"filter": { + "my_field": "my_value" +} +``` + +This way only resources with such a field and value are returned by the query. + +### List contains predicate + +Field values can be also compared towards a list of acceptable values. + +```json +"filter": { + "my_field": { + "in": ["a", "b", "c"] + } +} +``` + +This way only resources are returned where the respective field is a or b or c. + +### Nested predicates + +Filtering is also supported on nested structures of a resource. + +```json +"filter": { + "nested_resources": { + "filter": { + "nested_field": "nested_value" + } + } +} +``` diff --git a/src/schema.ts b/src/schema.ts index ccafbb9..183a938 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -29,6 +29,12 @@ const addObjectType = (app: express.Express, bundleSha: string, name: string, ob const getObjectType = (app: express.Express, bundleSha: string, name: string) => app.get('objectTypes')[bundleSha][name]; +const getInterfaceType = (app: express.Express, bundleSha: string, name: string) => app.get('objectInterfaces')[bundleSha][name]; + +const getGqlType = (app: express.Express, bundleSha: string, name: string) => ( + app.get('bundles')[bundleSha].schema.confs.find((t: any) => t.name === name) +); + const jsonType = new GraphQLScalarType({ name: 'JSON', serialize: JSON.stringify, @@ -82,46 +88,184 @@ const containsPredicate = (field: string, value: Set, source: any): bool field in source && value.has(source[field]) ); -const conditionsObjectPredicate = (field: string, value: any, source: any): boolean => { - switch (true) { - case 'in' in value: - return containsPredicate(field, new Set(value.in as Array), source); - default: - throw new GraphQLError( - `Condition object ${value} unsupported`, - ); +const isNonEmptyArray = (obj: any) => Array.isArray(obj) && obj.length > 0; + +const resolveValue = ( + app: express.Express, + bundleSha: string, + root: any, + context: any, + info: any, +): any => { + if (root == null) { + return null; } + // add root.$schema to the schemas extensions + if (typeof (root.$schema) !== 'undefined' && root.$schema) { + if ('schemas' in context) { + if (!context.schemas.includes(root.$schema)) { + context.schemas.push(root.$schema); + } + } else { + context.schemas = [root.$schema]; + } + } + + if (info.fieldName === 'schema') return root.$schema; + + const val = root[info.fieldName]; + + // if the item is null, return as is + if (typeof (val) === 'undefined') { return null; } + + if (isNonEmptyArray(val)) { + // are all the elements of this array references? + const checkRefs = val.map(isRef); + + // if there are elements that aren't references return the array as is + if (checkRefs.includes(false)) { + return val; + } + + // resolve all the elements of the array + let arrayResolve = val.map((x: db.Referencing) => db.resolveRef(app.get('bundles')[bundleSha], x)); + + // `info.returnType` has information about what the GraphQL schema expects + // as a return type. If it starts with `[` it means that we need to return + // an array. + if (String(info.returnType)[0] === '[') { + arrayResolve = arrayResolve.flat(1); + } + + return arrayResolve; + } + + if (isRef(val)) return db.resolveRef(app.get('bundles')[bundleSha], val); + return val; }; -const filterObjectPredicateBuilder = (gqlType: any): FilterPredicateBuilder => ( +const getFilters = ( + app: express.Express, + bundleSha: string, + name: string, +): FilterDict => app.get('searchableFields')[bundleSha][name]; + +const isConditionsObject = ( + conditionsObject: any, +): boolean => { + if (conditionsObject == null) { + return false; + } + return Object.prototype.hasOwnProperty.call(conditionsObject, 'in') || Object.prototype.hasOwnProperty.call(conditionsObject, 'filter'); +}; + +const filterPredicate = ( + field: string, + filter: any, + fieldGqlType: any, + app: express.Express, + bundleSha: string, + source: any, +): boolean => { + const filterSpecs = getFilters(app, bundleSha, fieldGqlType.name); + const fieldValue = resolveValue(app, bundleSha, source, {}, { fieldName: field }); + if (fieldValue == null) return false; + return filterSpecs.filter.predicateBuilder(filter)(fieldValue); +}; + +const conditionsObjectPredicate = ( + field: string, + value: any, + fieldGqlType: any, + app: express.Express, + bundleSha: string, + source: any, +): boolean => { + if (Object.prototype.hasOwnProperty.call(value, 'in')) { + return containsPredicate(field, new Set(value.in as Array), source); + } + if (Object.prototype.hasOwnProperty.call(value, 'filter')) { + return filterPredicate(field, value.filter, fieldGqlType, app, bundleSha, source); + } + throw new GraphQLError( + `Condition object ${value} unsupported`, + ); +}; + +const conditionsObjectPredicateDeconstructor = ( + field: string, + value: any, + fieldGqlType: any, + app: express.Express, + bundleSha: string, + source: any, +): boolean => { + const sources = Array.isArray(source) ? source : [source]; + return sources.every( + (e: any) => ( + conditionsObjectPredicate(field, value, fieldGqlType, app, bundleSha, e) + ), + ); +}; + +const getGraphGQLTypeFields = ( + app: express.Express, + bundleSha: string, + gqlTypeName: any, +): any => { + const gqlType = getGqlType(app, bundleSha, gqlTypeName); + let fieldsMap = new Map( + gqlType.fields.map( + (f: any) => [f.name, f], + ), + ); + + if (gqlType.isInterface && gqlType.interfaceResolve.strategy === 'fieldMap') { + Object.values(gqlType.interfaceResolve.fieldMap).forEach((typeName: string) => { + const interfaceFields = getGraphGQLTypeFields(app, bundleSha, typeName); + fieldsMap = new Map([...fieldsMap, ...interfaceFields]); + }); + } + return fieldsMap; +}; + +const filterObjectPredicateBuilder = ( + gqlType: any, + app: express.Express, + bundleSha: string, +): FilterPredicateBuilder => ( (filterObject: any): FilterPredicate => { - const supportedFieldsInSchema = new Map( - gqlType.fields.filter( - (f: any) => ['string', 'int', 'boolean'].includes(f.type), - ).map( - (f: any) => [f.name, f], - ), - ); + const supportedFieldsInSchema = getGraphGQLTypeFields(app, bundleSha, gqlType.name); if (typeof filterObject !== 'object') return falsePredicate; const filters: FilterPredicate[] = Object.entries(filterObject).map(([field, value]) => { + const fieldType = supportedFieldsInSchema.get(field); + if (fieldType == null) { + throw new GraphQLError( + `Field "${field}" does not exist on type "${gqlType.name}"`, + undefined, + null, + null, + null, + null, + { + code: 'BAD_FILTER_FIELD', + gqlType: gqlType.name, + }, + ); + } + const fieldGglType = getGqlType(app, bundleSha, fieldType.type); switch (true) { - case !supportedFieldsInSchema.has(field): - throw new GraphQLError( - `Field ${field} on ${gqlType.name} can not be used for filtering (yet)`, - undefined, - null, - null, - null, + case fieldType.isList && Array.isArray(value): + return arrayEqPredicate.bind(null, field, value); + case isConditionsObject(value): + return conditionsObjectPredicateDeconstructor.bind( null, - { - code: 'BAD_FILTER_FIELD', - gqlType: gqlType.name, - }, + field, + value, + fieldGglType, + app, + bundleSha, ); - case supportedFieldsInSchema.get(field).isList && Array.isArray(value): - return arrayEqPredicate.bind(null, field, value); - case typeof value === 'object' && value !== null: - return conditionsObjectPredicate.bind(null, field, value); default: return fieldEqPredicate.bind(null, field, value); } @@ -152,19 +296,13 @@ const registerFilterArgs = ( // generic filter object filters.filter = new Filter( - filterObjectPredicateBuilder(gqlType), + filterObjectPredicateBuilder(gqlType, app, bundleSha), jsonType, ); app.get('searchableFields')[bundleSha][gqlType.name] = filters; // eslint-disable-line no-param-reassign }; -const getFilters = ( - app: express.Express, - bundleSha: string, - name: string, -): FilterDict => app.get('searchableFields')[bundleSha][name]; - // interface types helpers const addInterfaceType = (app: express.Express, bundleSha: string, name: string, obj: any) => { if (typeof (app.get('objectInterfaces')[bundleSha]) === 'undefined') { @@ -173,8 +311,6 @@ const addInterfaceType = (app: express.Express, bundleSha: string, name: string, app.get('objectInterfaces')[bundleSha][name] = obj; // eslint-disable-line no-param-reassign }; -const getInterfaceType = (app: express.Express, bundleSha: string, name: string) => app.get('objectInterfaces')[bundleSha][name]; - // datafile types to GraphQL type const addDatafileSchema = ( app: express.Express, @@ -194,9 +330,6 @@ const getGraphqlTypeForDatafileSchema = ( datafileSchema: string, ) => app.get('datafileSchemas')[bundleSha][datafileSchema]; -// helpers -const isNonEmptyArray = (obj: any) => obj.constructor === Array && obj.length > 0; - // synthetic field resolver const resolveSyntheticField = ( bundle: db.Bundle, @@ -227,53 +360,35 @@ const resolveDatafileSchemaField = ( .filter((df: Datafile) => predicates.every((predicate) => predicate(df))); }; +const getInnerGqlType = ( + gqlType: any, +): any => { + if ('ofType' in gqlType) { + return getInnerGqlType(gqlType.ofType); + } + return gqlType; +}; + // default resolver export const defaultResolver = ( app: express.Express, bundleSha: string, ) => (root: any, args: any, context: any, info: any) => { - // add root.$schema to the schemas extensions - if (typeof (root.$schema) !== 'undefined' && root.$schema) { - if ('schemas' in context) { - if (!context.schemas.includes(root.$schema)) { - context.schemas.push(root.$schema); - } - } else { - context.schemas = [root.$schema]; - } - } - - if (info.fieldName === 'schema') return root.$schema; - - const val = root[info.fieldName]; - - // if the item is null, return as is - if (typeof (val) === 'undefined') { return null; } - - if (isNonEmptyArray(val)) { - // are all the elements of this array references? - const checkRefs = val.map(isRef); - - // if there are elements that aren't references return the array as is - if (checkRefs.includes(false)) { - return val; - } - - // resolve all the elements of the array - let arrayResolve = val.map((x: db.Referencing) => db.resolveRef(app.get('bundles')[bundleSha], x)); - - // `info.returnType` has information about what the GraphQL schema expects - // as a return type. If it starts with `[` it means that we need to return - // an array. - if (String(info.returnType)[0] === '[') { - arrayResolve = arrayResolve.flat(1); + const resolved = resolveValue(app, bundleSha, root, context, info); + if (Object.keys(args).length !== 0) { + const filterSpecs = getFilters(app, bundleSha, getInnerGqlType(info.returnType)); + const filterArgs = Object.entries(args) + .filter(([_, value]) => value != null); // eslint-disable-line no-unused-vars + + const predicates = filterArgs.map( + ([key, value]) => filterSpecs[key].predicateBuilder(value), + ); + if (Array.isArray(resolved)) { + return resolved.filter((e) => predicates.every((predicate) => predicate(e))); } - - return arrayResolve; + return !predicates.every((predicate) => predicate(resolved)); } - - if (isRef(val)) return db.resolveRef(app.get('bundles')[bundleSha], val); - return val; + return resolved; }; // ------------------ START SCHEMA ------------------ @@ -284,13 +399,6 @@ const createSchemaType = (app: express.Express, bundleSha: string, conf: any) => // name objTypeConf.name = conf.name; - // searchable fields - const searchableFieldNames = conf.fields - .filter((f: any) => f.isSearchable && f.type === 'string') - .map((f: any) => f.name); - - registerFilterArgs(app, bundleSha, conf, searchableFieldNames); - // fields objTypeConf.fields = () => conf.fields.reduce( (objFields: any, fieldInfo: any) => { diff --git a/test/filter/data.json b/test/filter/data.json index 4d1738e..ccb6258 100644 --- a/test/filter/data.json +++ b/test/filter/data.json @@ -30,52 +30,91 @@ "name": "resource F", "optional_field": "F", "list_field": ["C", "D", "E"] - } - }, - "graphql": [ - { - "name": "Resource_v1", - "fields": [ + }, + "/resource-g.yml": { + "$schema": "/resource-1.yml", + "name": "resource G", + "reference": { + "$ref": "/resource-a.yml" + } + }, + "/resource-h.yml": { + "$schema": "/resource-1.yml", + "name": "resource H", + "reference": { + "$ref": "/resource-d.yml" + }, + "reference_list": [ { - "isRequired": true, - "type": "string", - "name": "schema" + "$ref": "/resource-a.yml" }, { - "isRequired": true, - "type": "string", - "name": "path" + "$ref": "/resource-b.yml" }, { - "type": "string", - "name": "name", - "isRequired": true, - "isSearchable": true - }, + "$ref": "/resource-c.yml" + } + ] + } + }, + "graphql": { + "$schema" : "/app-interface/graphql-schemas-1.yml", + "confs": [ { - "type": "string", - "name": "optional_field", - "isRequired": false + "name": "Resource_v1", + "fields": [ + { + "isRequired": true, + "type": "string", + "name": "schema" + }, + { + "isRequired": true, + "type": "string", + "name": "path" + }, + { + "type": "string", + "name": "name", + "isRequired": true, + "isSearchable": true + }, + { + "type": "string", + "name": "optional_field", + "isRequired": false + }, + { + "type": "string", + "name": "list_field", + "isList": true, + "isRequired": false + }, + { + "type": "Resource_v1", + "name": "reference", + "isRequired": false + }, + { + "type": "Resource_v1", + "name": "reference_list", + "isList": true, + "isRequired": false + } + ] }, { - "type": "string", - "name": "list_field", - "isList": true, - "isRequired": false + "fields": [ + { + "type": "Resource_v1", + "name": "resources_v1", + "isList": true, + "datafileSchema": "/resource-1.yml" + } + ], + "name": "Query" } ] - }, - { - "fields": [ - { - "type": "Resource_v1", - "name": "resources_v1", - "isList": true, - "datafileSchema": "/resource-1.yml" - } - ], - "name": "Query" - } - ], + }, "resources": {} } diff --git a/test/filter/filter.test.ts b/test/filter/filter.test.ts index c122e84..24b2440 100644 --- a/test/filter/filter.test.ts +++ b/test/filter/filter.test.ts @@ -50,7 +50,7 @@ describe('pathobject', async () => { .set('content-type', 'application/json') .send({ query }); resp.should.have.status(200); - resp.body.data.test.length.should.equal(6); + resp.body.data.test.length.should.equal(8); }); it('filter object - field value eq', async () => { @@ -69,6 +69,22 @@ describe('pathobject', async () => { resp.body.data.test[0].name.should.equal('resource A'); }); + it('filter object - unknown field', async () => { + const query = ` + { + test: resources_v1(filter: {unknown_field: "resource A"}) { + name + } + } + `; + const resp = await chai.request(srv) + .post('/graphql') + .set('content-type', 'application/json') + .send({ query }); + resp.should.have.status(200); + resp.body.errors[0].message.should.equal('Field "unknown_field" does not exist on type "Resource_v1"'); + }); + it('filter object - in (contains) condition', async () => { const query = ` { @@ -114,7 +130,7 @@ describe('pathobject', async () => { .set('content-type', 'application/json') .send({ query }); resp.should.have.status(200); - resp.body.data.test.length.should.equal(1); + resp.body.data.test.length.should.equal(3); resp.body.data.test[0].name.should.equal('resource D'); }); @@ -147,6 +163,73 @@ describe('pathobject', async () => { .set('content-type', 'application/json') .send({ query }); resp.should.have.status(200); - new Set(resp.body.data.test.map((r: { name: string; }) => r.name)).should.deep.equal(new Set(['resource A', 'resource B', 'resource C', 'resource D'])); + new Set(resp.body.data.test.map((r: { name: string; }) => r.name)).should.deep.equal(new Set(['resource A', 'resource B', 'resource C', 'resource D', 'resource G', 'resource H'])); + }); + + it('filter object - referenced object - field value eq', async () => { + const query = ` + { + test: resources_v1(filter: {reference: {filter: {name: "resource A"}}}) { + name + } + } + `; + const resp = await chai.request(srv) + .post('/graphql') + .set('content-type', 'application/json') + .send({ query }); + resp.should.have.status(200); + resp.body.data.test.length.should.equal(1); + resp.body.data.test[0].name.should.equal('resource G'); + }); + + it('filter object - referenced object - unknown field', async () => { + const query = ` + { + test: resources_v1(filter: {reference: {filter: {unknown_field: "resource A"}}}) { + name + } + } + `; + const resp = await chai.request(srv) + .post('/graphql') + .set('content-type', 'application/json') + .send({ query }); + resp.should.have.status(200); + resp.body.errors[0].message.should.equal('Field "unknown_field" does not exist on type "Resource_v1"'); + }); + + it('filter object - referenced object - field null', async () => { + const query = ` + { + test: resources_v1(filter: {reference: {filter: {optional_field: null}}}) { + name + } + } + `; + const resp = await chai.request(srv) + .post('/graphql') + .set('content-type', 'application/json') + .send({ query }); + resp.should.have.status(200); + resp.body.data.test.length.should.equal(1); + resp.body.data.test[0].name.should.equal('resource H'); + }); + + it('filter object - referenced object - list field in', async () => { + const query = ` + { + test: resources_v1(filter: {reference_list: {filter: {name: {in: ["resource A", "resource B", "resource C", "resource D"]}}}}) { + name + } + } + `; + const resp = await chai.request(srv) + .post('/graphql') + .set('content-type', 'application/json') + .send({ query }); + resp.should.have.status(200); + resp.body.data.test.length.should.equal(1); + resp.body.data.test[0].name.should.equal('resource H'); }); });