diff --git a/src/__tests__/falsykeys.tests.ts b/src/__tests__/falsykeys.tests.ts index 787158d..705b578 100644 --- a/src/__tests__/falsykeys.tests.ts +++ b/src/__tests__/falsykeys.tests.ts @@ -1,9 +1,7 @@ - import { expect } from 'chai'; import { jsonToGraphQLQuery } from '../'; describe('jsonToGraphQLQuery() - falsy keys', () => { - it('does not include fields which value is false', () => { const query = { query: { @@ -82,4 +80,86 @@ describe('jsonToGraphQLQuery() - falsy keys', () => { ); }); + it('Includes the nested object if includeFalsyKeys is true', () => { + const query = { + query: { + Posts: { + id: true, + name: false + }, + Lorem: { + Ipsum: { + name: false + } + } + } + }; + expect(jsonToGraphQLQuery(query, { includeFalsyKeys: true })).to.equal( + 'query { Posts { id name } Lorem { Ipsum { name } } }' + ); + }); + + it('does not include the object if nested object has falsy values', () => { + const query = { + query: { + Posts: { + id: true, + name: false + }, + Lorem: { + Ipsum: { + name: false + } + } + } + }; + expect(jsonToGraphQLQuery(query)).to.equal('query { Posts { id } }'); + }); + + it('skip objects when deeply nested keys contain falsy values', () => { + const query = { + query: { + id: true, + Posts: { + id: true, + name: false + }, + Lorem: { + Ipsum: { + Dolor: { + Sit: { + amet: false + } + } + }, + details: { + name: false, + address: true + } + } + } + }; + expect(jsonToGraphQLQuery(query)).to.equal( + 'query { id Posts { id } Lorem { details { address } } }' + ); + }); + + it('Include values if nested object has falsy values and includeFalsyKeys is true', () => { + const query = { + query: { + Posts: { + id: true, + name: false + }, + Lorem: { + Ipsum: { + name: false + } + } + } + }; + expect(jsonToGraphQLQuery(query, { includeFalsyKeys: true })).to.equal( + 'query { Posts { id name } Lorem { Ipsum { name } } }' + ); + }); }); diff --git a/src/__tests__/mutations.tests.ts b/src/__tests__/mutations.tests.ts index d428877..940d94c 100644 --- a/src/__tests__/mutations.tests.ts +++ b/src/__tests__/mutations.tests.ts @@ -1,25 +1,24 @@ - import { expect } from 'chai'; import { jsonToGraphQLQuery } from '../'; describe('jsonToGraphQLQuery() - mutations', () => { - it('simple mutation', () => { const mutation = { mutation: { delete_post: { __args: { id: 1234 }, - id: true, + id: true } } }; expect(jsonToGraphQLQuery(mutation, { pretty: true })).to.equal( - `mutation { + `mutation { delete_post (id: 1234) { id } -}`); - }) +}` + ); + }); it('correctly converts mutations with no specified return fields', () => { const query = { @@ -35,7 +34,7 @@ describe('jsonToGraphQLQuery() - mutations', () => { expect(jsonToGraphQLQuery(query, { pretty: true })).to.equal( `mutation { create_post (title: "My Awesome Post", body: "This post is awesome!") -}`); +}` + ); }); - }); diff --git a/src/__tests__/name.tests.ts b/src/__tests__/name.tests.ts index 04ebcb4..ed777e2 100644 --- a/src/__tests__/name.tests.ts +++ b/src/__tests__/name.tests.ts @@ -9,18 +9,18 @@ describe('jsonToGraphQLQuery() - name', () => { lorem: { __aliasFor: 'Posts', __args: { - arg1: 20, + arg1: 20 }, - id: true, + id: true }, larem: { __aliasFor: 'Posts', __args: { - arg2: 10, + arg2: 10 }, - id: true, - }, - }, + id: true + } + } }; expect(jsonToGraphQLQuery(query)).to.equal( 'query NewName { lorem: Posts (arg1: 20) { id } larem: Posts (arg2: 10) { id } }' @@ -34,18 +34,18 @@ describe('jsonToGraphQLQuery() - name', () => { one: { __aliasFor: 'Posts', __args: { - arg1: 20, + arg1: 20 }, - id: true, + id: true }, two: { __aliasFor: 'Posts', __args: { - arg2: 10, + arg2: 10 }, - id: true, - }, - }, + id: true + } + } }; expect(jsonToGraphQLQuery(query)).to.equal( 'mutation NewName { one: Posts (arg1: 20) { id } two: Posts (arg2: 10) { id } }' @@ -62,12 +62,12 @@ describe('jsonToGraphQLQuery() - combinations', () => { Posts: { __args: { arg1: 20, - arg2: new VariableType('variable1'), + arg2: new VariableType('variable1') }, id: true, - title: true, - }, - }, + title: true + } + } }; expect(jsonToGraphQLQuery(query, { pretty: true })).to.equal( `query NewName { @@ -79,24 +79,23 @@ describe('jsonToGraphQLQuery() - combinations', () => { ); }); - it('correctly converts query with name/variables', () => { const query = { query: { __name: 'NewName', __variables: { variable1: 'String!', - variableWithDefault: 'String = "default_value"', + variableWithDefault: 'String = "default_value"' }, Posts: { __args: { arg1: 20, - arg2: new VariableType('variable1'), + arg2: new VariableType('variable1') }, id: true, - title: true, - }, - }, + title: true + } + } }; expect(jsonToGraphQLQuery(query, { pretty: true })).to.equal( `query NewName ($variable1: String!, $variableWithDefault: String = "default_value") { @@ -114,65 +113,65 @@ describe('jsonToGraphQLQuery() - combinations', () => { __name: 'NewName', __variables: { someString: 'String!', - varWithDefault: 'String = "default_value"', + varWithDefault: 'String = "default_value"' }, one: { __aliasFor: 'Posts', __args: { arg1: 20, arg2: new VariableType('someString'), - status: new EnumType('PUBLISHED'), + status: new EnumType('PUBLISHED') }, name: false, id: true, title: true, comments: { __args: { - offensiveOnly: true, + offensiveOnly: true }, id: true, comment: true, - user: true, - }, + user: true + } }, Post: { __args: { arg1: 20, - arg2: new VariableType('someString'), + arg2: new VariableType('someString') }, __on: { __typeName: 'ConfigurablePost', - id: true, + id: true }, name: false, title: true, comments: { __args: { - offensiveOnly: true, + offensiveOnly: true }, id: true, comment: true, - user: true, - }, + user: true + } }, Posts: { __args: { arg1: 20, - arg2: new VariableType('someString'), + arg2: new VariableType('someString') }, name: false, id: true, title: true, comments: { __args: { - offensiveOnly: true, + offensiveOnly: true }, id: true, comment: true, - user: true, - }, - }, - }, + user: true + } + } + } }; expect(jsonToGraphQLQuery(query, { pretty: true })).to.equal( `query NewName ($someString: String!, $varWithDefault: String = "default_value") { diff --git a/src/jsonToGraphQLQuery.ts b/src/jsonToGraphQLQuery.ts index 198115d..7c6dafb 100644 --- a/src/jsonToGraphQLQuery.ts +++ b/src/jsonToGraphQLQuery.ts @@ -2,7 +2,15 @@ import { EnumType } from './types/EnumType'; import { VariableType } from './types/VariableType'; export const configFields = [ - '__args', '__alias', '__aliasFor', '__variables', '__directives', '__on', '__all_on', '__typeName', '__name' + '__args', + '__alias', + '__aliasFor', + '__variables', + '__directives', + '__on', + '__all_on', + '__typeName', + '__name' ]; function stringify(obj_from_json: any): string { @@ -17,14 +25,12 @@ function stringify(obj_from_json: any): string { else if (typeof obj_from_json !== 'object' || obj_from_json === null) { // not an object, stringify using native function return JSON.stringify(obj_from_json); - } - else if (Array.isArray(obj_from_json)) { + } else if (Array.isArray(obj_from_json)) { return `[${obj_from_json.map((item) => stringify(item)).join(', ')}]`; } // Implements recursive object serialization according to JSON spec // but without quotes around the keys. - const props: string = Object - .keys(obj_from_json) + const props: string = Object.keys(obj_from_json) .map((key) => `${key}: ${stringify(obj_from_json[key])}`) .join(', '); @@ -50,40 +56,54 @@ function buildVariables(varsObj: any): string { function buildDirectives(dirsObj: any): string { const directiveName = Object.keys(dirsObj)[0]; const directiveValue = dirsObj[directiveName]; - if (typeof directiveValue === 'boolean' || (typeof directiveValue === 'object' && Object.keys(directiveValue).length === 0)) { + if ( + typeof directiveValue === 'boolean' || + (typeof directiveValue === 'object' && + Object.keys(directiveValue).length === 0) + ) { return directiveName; - } - else if (typeof directiveValue === 'object') { + } else if (typeof directiveValue === 'object') { const args = []; for (const argName in directiveValue) { const argVal = stringify(directiveValue[argName]).replace(/"/g, ''); args.push(`${argName}: ${argVal}`); } return `${directiveName}(${args.join(', ')})`; - } - else { - throw new Error(`Unsupported type for directive: ${typeof directiveValue}. Types allowed: object, boolean.\n` + - `Offending object: ${JSON.stringify(dirsObj)}`); + } else { + throw new Error( + `Unsupported type for directive: ${typeof directiveValue}. Types allowed: object, boolean.\n` + + `Offending object: ${JSON.stringify(dirsObj)}` + ); } } function getIndent(level: number): string { - return Array((level * 4) + 1).join(' '); + return Array(level * 4 + 1).join(' '); } function filterNonConfigFields(fieldName: string, ignoreFields: string[]) { // Returns true if fieldName is not a 'configField'. - return configFields.indexOf(fieldName) == -1 && ignoreFields.indexOf(fieldName) == -1; + return ( + configFields.indexOf(fieldName) == -1 && + ignoreFields.indexOf(fieldName) == -1 + ); } -function convertQuery(node: any, level: number, output: [string, number][], options: IJsonToGraphQLOptions) { +function convertQuery( + node: any, + level: number, + output: [string, number][], + options: IJsonToGraphQLOptions +) { Object.keys(node) .filter((key) => filterNonConfigFields(key, options.ignoreFields!)) .forEach((key) => { let value = node[key]; if (typeof value === 'object') { if (Array.isArray(value)) { - value = value.find((item) => item && typeof item === 'object'); + value = value.find( + (item) => item && typeof item === 'object' + ); if (!value) { output.push([`${key}`, level]); return; @@ -91,15 +111,23 @@ function convertQuery(node: any, level: number, output: [string, number][], opti } // Check if the object would be empty - if (value && Object.keys(value).filter(k => value[k] !== false || options.includeFalsyKeys).length === 0) { + if ( + value && + Object.keys(value).filter( + (k) => value[k] !== false || options.includeFalsyKeys + ).length === 0 + ) { // If so, we don't include it into the query return; } - const fieldCount = Object.keys(value) - .filter((keyCount) => filterNonConfigFields(keyCount, options.ignoreFields!)).length; + const fieldCount = Object.keys(value).filter((keyCount) => + filterNonConfigFields(keyCount, options.ignoreFields!) + ).length; const subFields = fieldCount > 0; - const argsExist = typeof value.__args === 'object' && Object.keys(value.__args).length > 0; + const argsExist = + typeof value.__args === 'object' && + Object.keys(value.__args).length > 0; const directivesExist = typeof value.__directives === 'object'; const fullFragmentsExist = value.__all_on instanceof Array; const partialFragmentsExist = typeof value.__on === 'object'; @@ -114,16 +142,21 @@ function convertQuery(node: any, level: number, output: [string, number][], opti token = `${token}: ${value.__aliasFor}`; } - if (typeof value.__variables === 'object' && Object.keys(value.__variables).length > 0) { + if ( + typeof value.__variables === 'object' && + Object.keys(value.__variables).length > 0 + ) { token = `${token} (${buildVariables(value.__variables)})`; - } - else if (argsExist || directivesExist) { + } else if (argsExist || directivesExist) { let argsStr = ''; let dirsStr = ''; if (directivesExist) { dirsStr = Object.entries(value.__directives) - .map(item => `@${buildDirectives({ [item[0]]: item[1] })}`) - .join(' ') + .map( + (item) => + `@${buildDirectives({ [item[0]]: item[1] })}` + ) + .join(' '); } if (argsExist) { argsStr = `(${buildArgs(value.__args)})`; @@ -132,7 +165,15 @@ function convertQuery(node: any, level: number, output: [string, number][], opti token = `${token} ${argsStr}${spacer}${dirsStr}`; } - output.push([token + (subFields || partialFragmentsExist || fullFragmentsExist ? ' {' : ''), level]); + output.push([ + token + + (subFields || + partialFragmentsExist || + fullFragmentsExist + ? ' {' + : ''), + level + ]); convertQuery(value, level + 1, output, options); if (fullFragmentsExist) { @@ -141,12 +182,17 @@ function convertQuery(node: any, level: number, output: [string, number][], opti }); } if (partialFragmentsExist) { - const inlineFragments: { __typeName: string }[] - = value.__on instanceof Array ? value.__on : [value.__on]; + const inlineFragments: { __typeName: string }[] = + value.__on instanceof Array ? value.__on : [value.__on]; inlineFragments.forEach((inlineFragment) => { const name = inlineFragment.__typeName; output.push([`... on ${name} {`, level + 1]); - convertQuery(inlineFragment, level + 2, output, options); + convertQuery( + inlineFragment, + level + 2, + output, + options + ); output.push(['}', level + 1]); }); } @@ -154,7 +200,6 @@ function convertQuery(node: any, level: number, output: [string, number][], opti if (subFields || partialFragmentsExist || fullFragmentsExist) { output.push(['}', level]); } - } else if (options.includeFalsyKeys === true || value) { output.push([`${key}`, level]); } @@ -167,7 +212,10 @@ export interface IJsonToGraphQLOptions { includeFalsyKeys?: boolean; } -export function jsonToGraphQLQuery(query: any, options: IJsonToGraphQLOptions = {}) { +export function jsonToGraphQLQuery( + query: any, + options: IJsonToGraphQLOptions = {} +) { if (!query || typeof query != 'object') { throw new Error('query object not specified'); } @@ -178,19 +226,91 @@ export function jsonToGraphQLQuery(query: any, options: IJsonToGraphQLOptions = options.ignoreFields = []; } - const queryLines: [string, number][] = []; + let queryLines: [string, number][] = []; convertQuery(query, 0, queryLines, options); + queryLines = dropEmptyObjects(queryLines); + let output = ''; queryLines.forEach(([line, level]) => { if (options.pretty) { - if (output) { output += '\n'; } + if (output) { + output += '\n'; + } output += getIndent(level) + line; - } - else { - if (output) { output += ' '; } + } else { + if (output) { + output += ' '; + } output += line; } }); return output; } + +type InputItem = [string, number]; +type OutputItem = [string, number]; + +interface IContext { + indent: number; + text: string | null; + isEmpty: boolean; + contents: Array; +} + +function dropEmptyObjects(inputList: Array): Array { + const rootContext: IContext = { + indent: -1, + text: null, + isEmpty: true, + contents: [] + }; + + const stack: IContext[] = [rootContext]; + + for (let [text, indent] of inputList) { + text = text.trim(); + if (text.endsWith('{')) { + const context: IContext = { + indent: indent, + text: text, + isEmpty: true, + contents: [] + }; + stack[stack.length - 1].contents.push(context); + stack.push(context); + } else if (text === '}') { + const context = stack.pop()!; + if (context.isEmpty) { + // Remove the context from its parent's contents + stack[stack.length - 1].contents.pop(); + } else { + // Mark the parent as not empty + stack[stack.length - 1].isEmpty = false; + } + } else { + // It's a field, add it to the current context + stack[stack.length - 1].contents.push(text); + stack[stack.length - 1].isEmpty = false; + } + } + + const output: OutputItem[] = []; + + function traverse(context: IContext): void { + for (const item of context.contents) { + if (typeof item === 'object') { + // It's a context + output.push([item.text!, item.indent]); + traverse(item); + output.push(['}', item.indent]); + } else { + // It's a field (string) + output.push([item, context.indent + 1]); + } + } + } + + traverse(rootContext); + return output; +} diff --git a/tslint.json b/tslint.json index 2e6811c..72e4c96 100644 --- a/tslint.json +++ b/tslint.json @@ -1,31 +1,29 @@ { - "extends": [ - "tslint:latest" - ], + "extends": ["tslint:latest"], "rules": { - "trailing-comma": [false], - "object-literal-sort-keys": false, - "object-literal-key-quotes": [true, "as-needed"], - "ordered-imports": false, - "no-console": [false], - "no-unused-new": false, - "triple-equals": false, - "one-line": [false, "check-catch", "check-finally", "check-else"], - "only-arrow-functions": [false], - "quotemark": [true, "single"], - "forin": false, - "variable-name": [false], - "no-empty": false, - "no-implicit-dependencies": false, - "no-string-literal": false, - "no-submodule-imports": false, - "object-literal-shorthand": false, - "member-access": [false], - "prefer-conditional-expression": false, - "prefer-for-of": false, - "member-ordering": [false], - "prefer-object-spread": false, - "eofline": false + "trailing-comma": [false], + "object-literal-sort-keys": false, + "object-literal-key-quotes": [true, "as-needed"], + "ordered-imports": false, + "no-console": [false], + "no-unused-new": false, + "triple-equals": false, + "one-line": [false, "check-catch", "check-finally", "check-else"], + "only-arrow-functions": [false], + "quotemark": [true, "single"], + "forin": false, + "variable-name": [false], + "no-empty": false, + "no-implicit-dependencies": false, + "no-string-literal": false, + "no-submodule-imports": false, + "object-literal-shorthand": false, + "member-access": [false], + "prefer-conditional-expression": false, + "prefer-for-of": false, + "member-ordering": [false], + "prefer-object-spread": false, + "eofline": false, + "array-type": false } - } - \ No newline at end of file +}