hooks_use-hermes_query.js

import { isObject } from '@gravityforms/utils';

/**
 * @function stringifyArg
 * @description Build a string representation of a variable for use in Hermes query.
 *
 * @since 4.0.5
 *
 * @param {*} arg The property to convert to a string.
 *
 * @return {string} The string representation of the property.
 */
export const stringifyArg = ( arg ) => {
	if ( arg === null || arg === undefined ) {
		return 'null';
	}
	const type = typeof arg;
	if ( type === 'string' ) {
		// Stringify to escape special characters.
		return JSON.stringify( arg );
	} else if ( ! isObject( arg ) && ! Array.isArray( arg ) ) {
		// Handle numbers, booleans, etc.
		return `"${ String( arg ) }"`;
	} else if ( Array.isArray( arg ) ) {
		// Handle arrays
		const elements = arg.map( ( e ) => stringifyArg( e ) );
		return '[' + elements.join( ', ' ) + ']';
	}
	// Handle objects
	const parts = [];
	for ( const key in arg ) {
		if ( Object.prototype.hasOwnProperty.call( arg, key ) ) {
			const strKey = String( key );
			const valueStr = stringifyArg( arg[ key ] );
			parts.push( `${ strKey }: ${ valueStr }` );
		}
	}
	return '{' + parts.join( ', ' ) + '}';
};

/**
 * @function validateAliases
 * @description Validates an array of objects to ensure three conditions related to `_alias` properties:
 *              1. At least (array length - 1) objects must have an `_alias` key.
 *              2. All `_alias` values must be unique.
 *              3. None of the `_alias` values must match `key`.
 *
 * @param {Array}  arr The array to validate.
 * @param {string} key The key to avoid.
 *
 * @return {boolean} Whether the array passes validation or not.
 */
export const validateAliases = ( arr, key ) => {
	const aliases = [];

	for ( const obj of arr ) {
		if ( Object.prototype.hasOwnProperty.call( obj, '_alias' ) ) {
			const alias = obj._alias;
			aliases.push( alias );

			// Early exit if current alias already matches the key
			if ( alias === key ) {
				return false;
			}
		} else if ( Object.prototype.hasOwnProperty.call( obj, '_transform' ) ) {
			const transformObj = obj._transform;
			if ( Object.prototype.hasOwnProperty.call( transformObj, '_alias' ) ) {
				const alias = transformObj._alias;
				aliases.push( alias );

				// Early exit if current alias already matches the key
				if ( alias === key ) {
					return false;
				}
			}
		}
	}

	// Minimum count requirement
	if ( aliases.length < ( arr.length - 1 ) ) {
		return false;
	}

	// Check for key matches (redundant check but included for clarity)
	if ( aliases.includes( key ) ) {
		return false;
	}

	// Uniqueness check
	const uniqueAliases = new Set( aliases );
	return uniqueAliases.size === aliases.length;
};

/**
 * @function buildArgsString
 * @description Build an arguments string from an object for use in Hermes query.
 *
 * @since 4.1.0
 *
 * @param {object}  args                The object to convert to a string.
 * @param {boolean} returnParensOnEmpty Whether to return parentheses around the args string if it is empty.
 *
 * @return {string} The arguments string.
 */
export const buildArgsString = ( args = {}, returnParensOnEmpty = false ) => {
	const argsStr = Object.entries( args )
		.map( ( [ argKey, argValue ] ) => `${ argKey }: ${ stringifyArg( argValue ) }` )
		.join( ', ' );
	if ( argsStr ) {
		return `(${ argsStr })`;
	}
	return returnParensOnEmpty ? '()' : '';
};

/**
 * @function buildQueryStringPart
 * @description Build a query string part from a query object and key for use in Hermes query.
 *
 * @since 4.1.0
 *
 * @throws {Error} If transform does not have `_alias` and `_args` keys.
 *
 * @param {object} obj The query object to convert to a string.
 * @param {string} key The key of the query object.
 *
 * @return {string} The query string part.
 */
export const buildQueryStringPart = ( obj, key ) => {
	// If obj has only one key and the key is _alias, return the aliased key.
	if ( Object.keys( obj ).length === 1 && obj._alias ) {
		return `${ obj._alias }: ${ key }`;
	}

	// If obj has only one key and the key is _transform, return the transform query string part.
	if ( Object.keys( obj ).length === 1 && obj._transform ) {
		const transformObj = obj._transform;
		if ( ! Object.prototype.hasOwnProperty.call( transformObj, '_alias' ) || ! Object.prototype.hasOwnProperty.call( transformObj, '_args' ) ) {
			throw new Error( 'Transforms must have `_alias` and `_args` keys.' );
		}
		const argsPart = buildArgsString( transformObj._args, true );
		const fieldName = `${ transformObj._alias }: ${ key }`;
		return `${ fieldName }${ argsPart }`;
	}

	// Extract args from the object.
	const args = obj._args || {};
	const argsPart = buildArgsString( args );

	// Extract alias from the object.
	const fieldName = obj._alias ? `${ obj._alias }: ${ key }` : key;

	// Build nested fields.
	const nestedObj = {};
	for ( const subKey in obj ) {
		if ( ! subKey.startsWith( '_' ) ) {
			nestedObj[ subKey ] = obj[ subKey ];
		}
	}
	const nestedStr = buildQueryStringRecursive( nestedObj );

	return `${ fieldName }${ argsPart } ${ nestedStr }`;
};

/**
 * @function buildQueryStringRecursive
 * @description Recursively build a query string from a query object for use in Hermes query.
 *
 * @since 4.0.5
 *
 * @throws {Error} If an array of queries for the same key do not have aliases.
 *
 * @param {object|Array} obj The query object or array to convert to a string.
 *
 * @return {string} The query string.
 */
export const buildQueryStringRecursive = ( obj = {} ) => {
	const parts = [];

	for ( const key in obj ) {
		// Skip private properties.
		if ( key.startsWith( '_' ) ) {
			continue;
		}

		const value = obj[ key ];
		if ( Array.isArray( value ) ) {
			// Check that an alias exists for at least n-1 entries and are unique.
			if ( ! validateAliases( value, key ) ) {
				throw new Error( 'Aliases that do not match the key are required for an array of queries.' );
			}
			// Process each array item as separate field.
			for ( const item of value ) {
				parts.push( buildQueryStringPart( item, key ) );
			}
		} else if ( isObject( value ) ) {
			// Handle nested objects (non-array, non-null).
			parts.push( buildQueryStringPart( value, key ) );
		} else if ( value === true ) {
			parts.push( key );
		}
	}

	return `{${ parts.join( ', ' ) }}`;
};

/**
 * @function buildQueryString
 * @description Build a query string from a query object for use in Hermes query.
 *
 * @since 4.0.5
 *
 * @param {object} queryObj The query object to convert to a string.
 *
 * @return {string} The query string.
 *
 * @example
 * const queryObj = {
 *   company: {
 *     _alias: 'first_ten_companies',
 *     _args: {
 *       limit: 10,
 *     },
 *     id: true,
 *     name: {
 *       _alias: 'business_name',
 *     },
 *     address: true,
 *     department: {
 *       id: {
 *         _alias: 'department_id',
 *       },
 *       name: true,
 *     },
 *   },
 * };
 *
 * const queryString = buildQueryString( queryObj );
 * // queryString = '{first_ten_companies: company(limit: 10) {id, business_name: name, address, department {department_id: id, name}}}';
 *
 * const arrayQueryObj = {
 *   company: [
 *     {
 *       _args: {
 *         limit: 2,
 *       },
 *       id: true,
 *     },
 *     {
 *       _alias: 'pagination',
 *       _args: {
 *         limit: 1,
 *       },
 *       aggregate: true,
 *     },
 *   ],
 * };
 * const arrayQueryString = buildQueryString( arrayQueryObj );
 * // arrayQueryString = '{company(limit: 2) {id}, pagination: company(limit: 1) {aggregate}}';
 *
 * const transformQueryObj = {
 *   company: {
 *     companyLogo: {
 *       _transform: {
 *         _alias: 'companyLogoSrc',
 *         _args: {
 *           transformMakeThumb: 'medium',
 *         },
 *       },
 *     },
 *   },
 * };
 * const transformQueryString = buildQueryString( transformQueryObj );
 * // transformQueryString = '{company {companyLogoSrc: companyLogo(trahsformMakeThumb: "medium")}}';
 *
 */
export const buildQueryString = ( queryObj = {} ) => {
	const result = buildQueryStringRecursive( queryObj );

	return result.trim();
};