mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-09 06:55:03 +00:00
feat: graphql query builder (#7468)
* feat: graphql query builder * fix: bug * improvements * fix * fix: playright test * fix * fix * improvements * chore: types * fix * chore: minimal error boundary * imp: use button component --------- Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
715
packages/bruno-app/src/utils/graphql/queryBuilder.js
Normal file
715
packages/bruno-app/src/utils/graphql/queryBuilder.js
Normal file
@@ -0,0 +1,715 @@
|
||||
import {
|
||||
isScalarType,
|
||||
isEnumType,
|
||||
isObjectType,
|
||||
isInterfaceType,
|
||||
isUnionType,
|
||||
isInputObjectType,
|
||||
isNonNullType,
|
||||
isListType,
|
||||
getNamedType,
|
||||
parse as gqlParse,
|
||||
print,
|
||||
Kind
|
||||
} from 'graphql';
|
||||
|
||||
const MAX_DEPTH = 7;
|
||||
|
||||
export const PLACEHOLDER = '__bruno_placeholder__';
|
||||
|
||||
const sanitizeQueryForParsing = (queryString) => {
|
||||
let sanitized = queryString.replace(/\(\s*\)/g, '');
|
||||
sanitized = sanitized.replace(/(:\s*)\{\s*\}/g, '$1{ __empty: true }');
|
||||
sanitized = sanitized.replace(/\{\s*\}/g, `{ ${PLACEHOLDER} }`);
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const resolveRootType = (schema, rootTypeName) => {
|
||||
switch (rootTypeName) {
|
||||
case 'Query': return schema.getQueryType();
|
||||
case 'Mutation': return schema.getMutationType();
|
||||
case 'Subscription': return schema.getSubscriptionType();
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type) => {
|
||||
if (isNonNullType(type)) return `${getTypeLabel(type.ofType)}!`;
|
||||
if (isListType(type)) return `[${getTypeLabel(type.ofType)}]`;
|
||||
return type.name;
|
||||
};
|
||||
|
||||
const isLeafType = (type) => {
|
||||
const named = getNamedType(type);
|
||||
return isScalarType(named) || isEnumType(named);
|
||||
};
|
||||
|
||||
const containsListType = (type) => {
|
||||
if (isListType(type)) return true;
|
||||
if (isNonNullType(type)) return containsListType(type.ofType);
|
||||
return false;
|
||||
};
|
||||
|
||||
const typeToAST = (type) => {
|
||||
if (isNonNullType(type)) {
|
||||
return { kind: Kind.NON_NULL_TYPE, type: typeToAST(type.ofType) };
|
||||
}
|
||||
if (isListType(type)) {
|
||||
return { kind: Kind.LIST_TYPE, type: typeToAST(type.ofType) };
|
||||
}
|
||||
return { kind: Kind.NAMED_TYPE, name: { kind: Kind.NAME, value: type.name } };
|
||||
};
|
||||
|
||||
const buildTypeDescriptor = (field) => {
|
||||
const named = getNamedType(field.type);
|
||||
return {
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
namedType: named,
|
||||
typeLabel: getTypeLabel(field.type),
|
||||
description: field.description || null,
|
||||
isRequired: isNonNullType(field.type),
|
||||
isEnum: isEnumType(named),
|
||||
enumValues: isEnumType(named) ? named.getValues().map((v) => v.value) : null,
|
||||
isBoolean: named?.name === 'Boolean',
|
||||
isInputObject: isInputObjectType(named),
|
||||
isList: containsListType(field.type)
|
||||
};
|
||||
};
|
||||
|
||||
const buildFieldDescriptor = (field, parentPath) => {
|
||||
const named = getNamedType(field.type);
|
||||
const path = parentPath ? `${parentPath}.${field.name}` : field.name;
|
||||
|
||||
return {
|
||||
name: field.name,
|
||||
path,
|
||||
type: field.type,
|
||||
namedType: named,
|
||||
typeLabel: getTypeLabel(field.type),
|
||||
isLeaf: isLeafType(field.type),
|
||||
isDeprecated: field.isDeprecated || false,
|
||||
deprecationReason: field.deprecationReason || null,
|
||||
description: field.description || null,
|
||||
args: (field.args || []).map((arg) => ({
|
||||
...buildTypeDescriptor(arg),
|
||||
defaultValue: arg.defaultValue
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
export const getFieldChildren = (namedType, parentPath) => {
|
||||
if (!namedType) {
|
||||
return { fields: [] };
|
||||
}
|
||||
|
||||
if (isObjectType(namedType) || isInterfaceType(namedType)) {
|
||||
const fieldMap = namedType.getFields();
|
||||
const fields = Object.values(fieldMap).map((field) => buildFieldDescriptor(field, parentPath));
|
||||
return { fields };
|
||||
}
|
||||
|
||||
if (isUnionType(namedType)) {
|
||||
const types = namedType.getTypes();
|
||||
const unionTypes = types.map((type) => ({
|
||||
name: type.name,
|
||||
path: `${parentPath}.__on_${type.name}`,
|
||||
type,
|
||||
namedType: type,
|
||||
isUnionMember: true
|
||||
}));
|
||||
return { fields: [], unionTypes };
|
||||
}
|
||||
|
||||
return { fields: [] };
|
||||
};
|
||||
|
||||
export const getInputObjectFields = (namedType) => {
|
||||
if (!namedType || !isInputObjectType(namedType)) return [];
|
||||
const fieldMap = namedType.getFields();
|
||||
return Object.values(fieldMap).map(buildTypeDescriptor);
|
||||
};
|
||||
|
||||
export const getRootFields = (schema, rootTypeName) => {
|
||||
if (!schema) return [];
|
||||
const rootType = resolveRootType(schema, rootTypeName);
|
||||
if (!rootType) return [];
|
||||
const fieldMap = rootType.getFields();
|
||||
return Object.values(fieldMap).map((field) => buildFieldDescriptor(field, rootTypeName));
|
||||
};
|
||||
|
||||
export const getAvailableRootTypes = (schema) => {
|
||||
if (!schema) return [];
|
||||
const types = [];
|
||||
if (schema.getQueryType()) types.push('Query');
|
||||
if (schema.getMutationType()) types.push('Mutation');
|
||||
if (schema.getSubscriptionType()) types.push('Subscription');
|
||||
return types;
|
||||
};
|
||||
|
||||
const collectVariablesForInputObject = (parentKey, inputType, enabledArgs, varMap, usedNames) => {
|
||||
if (!inputType || !isInputObjectType(inputType)) return;
|
||||
const fieldMap = inputType.getFields();
|
||||
|
||||
for (const [fieldName, field] of Object.entries(fieldMap)) {
|
||||
const fieldKey = `${parentKey}.${fieldName}`;
|
||||
if (!enabledArgs.has(fieldKey)) continue;
|
||||
|
||||
const named = getNamedType(field.type);
|
||||
if (isInputObjectType(named)) {
|
||||
collectVariablesForInputObject(fieldKey, named, enabledArgs, varMap, usedNames);
|
||||
} else {
|
||||
let varName = fieldName;
|
||||
if (usedNames.has(varName)) {
|
||||
const parts = parentKey.split('.');
|
||||
varName = `${parts[parts.length - 1]}_${fieldName}`;
|
||||
let i = 2;
|
||||
while (usedNames.has(varName)) {
|
||||
varName = `${fieldName}_${i}`;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
usedNames.add(varName);
|
||||
varMap.set(fieldKey, { varName, type: field.type });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const collectVariablesFromSelections = (selections, enabledArgs, type, parentPath, visited, depth, varMap, usedNames) => {
|
||||
if (!type || depth > MAX_DEPTH || visited.has(type.name)) return;
|
||||
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(type.name);
|
||||
|
||||
if (isUnionType(type)) {
|
||||
for (const memberType of type.getTypes()) {
|
||||
const memberPath = `${parentPath}.__on_${memberType.name}`;
|
||||
let isMemberSelected = selections.has(memberPath);
|
||||
if (!isMemberSelected) {
|
||||
for (const s of selections) {
|
||||
if (s.startsWith(memberPath + '.')) {
|
||||
isMemberSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isMemberSelected) continue;
|
||||
collectVariablesFromSelections(selections, enabledArgs, memberType, memberPath, nextVisited, depth + 1, varMap, usedNames);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isObjectType(type) && !isInterfaceType(type)) return;
|
||||
|
||||
const fieldMap = type.getFields();
|
||||
for (const [fieldName, field] of Object.entries(fieldMap)) {
|
||||
const fieldPath = `${parentPath}.${fieldName}`;
|
||||
if (!selections.has(fieldPath)) continue;
|
||||
|
||||
if (field.args) {
|
||||
for (const arg of field.args) {
|
||||
const argKey = `${fieldPath}.${arg.name}`;
|
||||
if (!enabledArgs || !enabledArgs.has(argKey)) continue;
|
||||
|
||||
const named = getNamedType(arg.type);
|
||||
if (isInputObjectType(named)) {
|
||||
collectVariablesForInputObject(argKey, named, enabledArgs, varMap, usedNames);
|
||||
} else {
|
||||
let varName = arg.name;
|
||||
if (usedNames.has(varName)) {
|
||||
varName = `${fieldName}_${arg.name}`;
|
||||
let i = 2;
|
||||
while (usedNames.has(varName)) {
|
||||
varName = `${fieldName}_${arg.name}_${i}`;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
usedNames.add(varName);
|
||||
varMap.set(argKey, { varName, type: arg.type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const named = getNamedType(field.type);
|
||||
if (!isLeafType(field.type) && named) {
|
||||
collectVariablesFromSelections(selections, enabledArgs, named, fieldPath, nextVisited, depth + 1, varMap, usedNames);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const coerceScalarValue = (value, namedType) => {
|
||||
if (value === undefined || value === '' || value === null) return null;
|
||||
if (namedType.name === 'Boolean') return value === true || value === 'true';
|
||||
if (namedType.name === 'Int') {
|
||||
const n = parseInt(value, 10);
|
||||
return isNaN(n) ? value : n;
|
||||
}
|
||||
if (namedType.name === 'Float') {
|
||||
const n = parseFloat(value);
|
||||
return isNaN(n) ? value : n;
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const buildVariableValue = (argKey, argType, argValues) => {
|
||||
const named = getNamedType(argType);
|
||||
const raw = argValues.get(argKey);
|
||||
if (Array.isArray(raw)) {
|
||||
const items = raw.map((v) => coerceScalarValue(v, named)).filter((v) => v !== null);
|
||||
return items.length > 0 ? items : null;
|
||||
}
|
||||
return coerceScalarValue(raw, named);
|
||||
};
|
||||
|
||||
const placeholderSelectionSet = () => ({
|
||||
kind: Kind.SELECTION_SET,
|
||||
selections: [
|
||||
{
|
||||
kind: Kind.FIELD,
|
||||
name: { kind: Kind.NAME, value: PLACEHOLDER }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const buildSelectionSetAST = (selections, namedType, parentPath, visited, depth, enabledArgs, varMap) => {
|
||||
if (!namedType || depth > MAX_DEPTH || visited.has(namedType.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(namedType.name);
|
||||
|
||||
if (isUnionType(namedType)) {
|
||||
return buildUnionSelectionSet(selections, namedType, parentPath, nextVisited, depth, enabledArgs, varMap);
|
||||
}
|
||||
|
||||
if (!isObjectType(namedType) && !isInterfaceType(namedType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldMap = namedType.getFields();
|
||||
const fieldSelections = [];
|
||||
|
||||
for (const [fieldName, field] of Object.entries(fieldMap)) {
|
||||
const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
||||
if (!selections.has(fieldPath)) continue;
|
||||
|
||||
const fieldAST = buildFieldAST(selections, field, fieldPath, nextVisited, depth, enabledArgs, varMap);
|
||||
if (fieldAST) {
|
||||
fieldSelections.push(fieldAST);
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldSelections.length === 0) return null;
|
||||
|
||||
return {
|
||||
kind: Kind.SELECTION_SET,
|
||||
selections: fieldSelections
|
||||
};
|
||||
};
|
||||
|
||||
const buildFieldAST = (selections, field, fieldPath, visited, depth, enabledArgs, varMap) => {
|
||||
const named = getNamedType(field.type);
|
||||
const args = buildArgumentsAST(field, fieldPath, enabledArgs, varMap);
|
||||
|
||||
let selectionSet = null;
|
||||
if (!isLeafType(field.type)) {
|
||||
selectionSet = buildSelectionSetAST(selections, named, fieldPath, visited, depth + 1, enabledArgs, varMap);
|
||||
if (!selectionSet) {
|
||||
selectionSet = placeholderSelectionSet();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: Kind.FIELD,
|
||||
name: { kind: Kind.NAME, value: field.name },
|
||||
arguments: args.length > 0 ? args : undefined,
|
||||
selectionSet: selectionSet || undefined
|
||||
};
|
||||
};
|
||||
|
||||
const buildInputObjectWithVariables = (parentKey, inputType, enabledArgs, varMap) => {
|
||||
if (!inputType || !isInputObjectType(inputType)) return null;
|
||||
const fieldMap = inputType.getFields();
|
||||
const fields = [];
|
||||
|
||||
for (const [fieldName, field] of Object.entries(fieldMap)) {
|
||||
const fieldKey = `${parentKey}.${fieldName}`;
|
||||
if (!enabledArgs.has(fieldKey)) continue;
|
||||
|
||||
const named = getNamedType(field.type);
|
||||
if (isInputObjectType(named)) {
|
||||
const nestedObj = buildInputObjectWithVariables(fieldKey, named, enabledArgs, varMap);
|
||||
if (nestedObj) {
|
||||
fields.push({
|
||||
kind: Kind.OBJECT_FIELD,
|
||||
name: { kind: Kind.NAME, value: fieldName },
|
||||
value: nestedObj
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const varInfo = varMap.get(fieldKey);
|
||||
if (varInfo) {
|
||||
fields.push({
|
||||
kind: Kind.OBJECT_FIELD,
|
||||
name: { kind: Kind.NAME, value: fieldName },
|
||||
value: { kind: Kind.VARIABLE, name: { kind: Kind.NAME, value: varInfo.varName } }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
return { kind: Kind.OBJECT, fields };
|
||||
};
|
||||
|
||||
const buildArgumentsAST = (field, fieldPath, enabledArgs, varMap) => {
|
||||
if (!field.args || field.args.length === 0) return [];
|
||||
|
||||
const args = [];
|
||||
for (const arg of field.args) {
|
||||
const key = `${fieldPath}.${arg.name}`;
|
||||
if (enabledArgs && !enabledArgs.has(key)) continue;
|
||||
|
||||
const named = getNamedType(arg.type);
|
||||
if (isInputObjectType(named)) {
|
||||
const objValue = buildInputObjectWithVariables(key, named, enabledArgs, varMap);
|
||||
if (objValue) {
|
||||
args.push({
|
||||
kind: Kind.ARGUMENT,
|
||||
name: { kind: Kind.NAME, value: arg.name },
|
||||
value: objValue
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const varInfo = varMap.get(key);
|
||||
if (varInfo) {
|
||||
args.push({
|
||||
kind: Kind.ARGUMENT,
|
||||
name: { kind: Kind.NAME, value: arg.name },
|
||||
value: { kind: Kind.VARIABLE, name: { kind: Kind.NAME, value: varInfo.varName } }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const buildUnionSelectionSet = (selections, unionType, parentPath, visited, depth, enabledArgs, varMap) => {
|
||||
const memberTypes = unionType.getTypes();
|
||||
const inlineFragments = [];
|
||||
|
||||
for (const memberType of memberTypes) {
|
||||
const memberPath = `${parentPath}.__on_${memberType.name}`;
|
||||
let isMemberSelected = selections.has(memberPath);
|
||||
if (!isMemberSelected) {
|
||||
for (const s of selections) {
|
||||
if (s.startsWith(memberPath + '.')) {
|
||||
isMemberSelected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMemberSelected) continue;
|
||||
|
||||
let selectionSet = buildSelectionSetAST(selections, memberType, memberPath, visited, depth + 1, enabledArgs, varMap);
|
||||
if (!selectionSet) {
|
||||
selectionSet = placeholderSelectionSet();
|
||||
}
|
||||
inlineFragments.push({
|
||||
kind: Kind.INLINE_FRAGMENT,
|
||||
typeCondition: {
|
||||
kind: Kind.NAMED_TYPE,
|
||||
name: { kind: Kind.NAME, value: memberType.name }
|
||||
},
|
||||
selectionSet
|
||||
});
|
||||
}
|
||||
|
||||
if (inlineFragments.length === 0) return null;
|
||||
|
||||
return {
|
||||
kind: Kind.SELECTION_SET,
|
||||
selections: inlineFragments
|
||||
};
|
||||
};
|
||||
|
||||
export const generateQueryString = (selections, argValues, schema, rootTypeName, enabledArgs, existingOperationName) => {
|
||||
if (!schema || !selections || selections.size === 0) return { query: '', variables: {} };
|
||||
const rootType = resolveRootType(schema, rootTypeName);
|
||||
if (!rootType) return { query: '', variables: {} };
|
||||
|
||||
const varMap = new Map();
|
||||
const usedNames = new Set();
|
||||
collectVariablesFromSelections(selections, enabledArgs, rootType, rootTypeName, new Set(), 0, varMap, usedNames);
|
||||
|
||||
const selectionSet = buildSelectionSetAST(selections, rootType, rootTypeName, new Set(), 0, enabledArgs, varMap);
|
||||
if (!selectionSet) return { query: '', variables: {} };
|
||||
|
||||
const operation = rootTypeName === 'Query' ? 'query' : rootTypeName === 'Mutation' ? 'mutation' : 'subscription';
|
||||
|
||||
let operationName = existingOperationName;
|
||||
if (!operationName) {
|
||||
const firstField = selectionSet.selections.find((s) => s.kind === Kind.FIELD);
|
||||
operationName = firstField
|
||||
? firstField.name.value.charAt(0).toUpperCase() + firstField.name.value.slice(1)
|
||||
: rootTypeName;
|
||||
}
|
||||
|
||||
const variableDefinitions = [];
|
||||
const variableValues = {};
|
||||
for (const [argKey, { varName, type }] of varMap) {
|
||||
variableDefinitions.push({
|
||||
kind: Kind.VARIABLE_DEFINITION,
|
||||
variable: { kind: Kind.VARIABLE, name: { kind: Kind.NAME, value: varName } },
|
||||
type: typeToAST(type)
|
||||
});
|
||||
const val = buildVariableValue(argKey, type, argValues);
|
||||
if (val !== null && val !== undefined) {
|
||||
variableValues[varName] = val;
|
||||
}
|
||||
}
|
||||
|
||||
const document = {
|
||||
kind: Kind.DOCUMENT,
|
||||
definitions: [
|
||||
{
|
||||
kind: Kind.OPERATION_DEFINITION,
|
||||
operation,
|
||||
name: { kind: Kind.NAME, value: operationName },
|
||||
variableDefinitions: variableDefinitions.length > 0 ? variableDefinitions : undefined,
|
||||
selectionSet
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
let result = print(document);
|
||||
result = result.replace(new RegExp(`^\\s*${PLACEHOLDER}\\n`, 'gm'), '');
|
||||
return { query: result, variables: variableValues };
|
||||
};
|
||||
|
||||
export const validateQueryForSync = (queryString) => {
|
||||
if (!queryString || !queryString.trim()) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = gqlParse(sanitizeQueryForParsing(queryString));
|
||||
} catch {
|
||||
return { valid: false, error: null };
|
||||
}
|
||||
|
||||
const operations = doc.definitions.filter((d) => d.kind === Kind.OPERATION_DEFINITION);
|
||||
if (operations.length === 0) {
|
||||
return { valid: false, error: null };
|
||||
}
|
||||
|
||||
if (operations.length > 1) {
|
||||
return { valid: false, error: 'multiple_operations' };
|
||||
}
|
||||
|
||||
return { valid: true, error: null };
|
||||
};
|
||||
|
||||
export const parseQueryToState = (queryString, schema, variablesString) => {
|
||||
if (!schema) return null;
|
||||
|
||||
if (!queryString || !queryString.trim()) {
|
||||
return { selections: new Set(), expandedPaths: new Set(), argValues: new Map(), enabledArgs: new Set() };
|
||||
}
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = gqlParse(sanitizeQueryForParsing(queryString));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let variablesJson = {};
|
||||
if (variablesString) {
|
||||
try {
|
||||
variablesJson = JSON.parse(variablesString);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const selections = new Set();
|
||||
const expandedPaths = new Set();
|
||||
const argValues = new Map();
|
||||
const enabledArgs = new Set();
|
||||
|
||||
for (const def of doc.definitions) {
|
||||
if (def.kind !== Kind.OPERATION_DEFINITION) continue;
|
||||
const rootTypeName = def.operation.charAt(0).toUpperCase() + def.operation.slice(1);
|
||||
const rootType = resolveRootType(schema, rootTypeName);
|
||||
if (!rootType || !def.selectionSet) continue;
|
||||
walkSelectionSet(def.selectionSet, rootType, rootTypeName, selections, expandedPaths, argValues, enabledArgs, variablesJson, schema);
|
||||
}
|
||||
|
||||
return { selections, expandedPaths, argValues, enabledArgs };
|
||||
};
|
||||
|
||||
const walkInputObjectValue = (valueNode, inputType, parentKey, argValues, enabledArgs, variablesJson) => {
|
||||
const objNode = valueNode.kind === Kind.LIST && valueNode.values.length > 0
|
||||
? valueNode.values[0]
|
||||
: valueNode;
|
||||
|
||||
if (objNode.kind !== Kind.OBJECT || !isInputObjectType(inputType)) return;
|
||||
|
||||
const fieldMap = inputType.getFields();
|
||||
|
||||
for (const astField of objNode.fields) {
|
||||
const fieldName = astField.name.value;
|
||||
if (fieldName === '__empty') continue;
|
||||
const fieldKey = `${parentKey}.${fieldName}`;
|
||||
enabledArgs.add(fieldKey);
|
||||
|
||||
const fieldDef = fieldMap[fieldName];
|
||||
const fieldNamed = fieldDef ? getNamedType(fieldDef.type) : null;
|
||||
|
||||
if (fieldNamed && isInputObjectType(fieldNamed)) {
|
||||
walkInputObjectValue(astField.value, fieldNamed, fieldKey, argValues, enabledArgs, variablesJson);
|
||||
} else if (astField.value.kind === Kind.VARIABLE) {
|
||||
const varName = astField.value.name.value;
|
||||
const varValue = variablesJson[varName];
|
||||
if (varValue !== undefined && varValue !== null) {
|
||||
argValues.set(fieldKey, String(varValue));
|
||||
}
|
||||
} else {
|
||||
const value = astValueToString(astField.value);
|
||||
if (value !== null && value !== '') {
|
||||
argValues.set(fieldKey, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walkVariableInputObject = (value, inputType, parentKey, argValues, enabledArgs) => {
|
||||
if (!value || typeof value !== 'object' || !isInputObjectType(inputType)) return;
|
||||
|
||||
const obj = Array.isArray(value) ? value[0] : value;
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
|
||||
const fieldMap = inputType.getFields();
|
||||
for (const [fieldName, fieldValue] of Object.entries(obj)) {
|
||||
const fieldKey = `${parentKey}.${fieldName}`;
|
||||
enabledArgs.add(fieldKey);
|
||||
|
||||
const fieldDef = fieldMap[fieldName];
|
||||
if (!fieldDef) continue;
|
||||
const fieldNamed = getNamedType(fieldDef.type);
|
||||
|
||||
if (isInputObjectType(fieldNamed)) {
|
||||
walkVariableInputObject(fieldValue, fieldNamed, fieldKey, argValues, enabledArgs);
|
||||
} else if (fieldValue !== null && fieldValue !== undefined) {
|
||||
argValues.set(fieldKey, String(fieldValue));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walkSelectionSet = (selectionSet, parentType, parentPath, selections, expandedPaths, argValues, enabledArgs, variablesJson, schema, depth = 0) => {
|
||||
if (!selectionSet || !selectionSet.selections || depth > MAX_DEPTH) return;
|
||||
|
||||
const fieldMap = (isObjectType(parentType) || isInterfaceType(parentType))
|
||||
? parentType.getFields()
|
||||
: null;
|
||||
|
||||
for (const sel of selectionSet.selections) {
|
||||
if (sel.kind === Kind.FIELD) {
|
||||
const fieldName = sel.name.value;
|
||||
if (fieldName === '__typename' || fieldName === PLACEHOLDER) continue;
|
||||
const fieldPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
|
||||
|
||||
selections.add(fieldPath);
|
||||
|
||||
if (sel.arguments && sel.arguments.length > 0 && fieldMap && fieldMap[fieldName]) {
|
||||
for (const argNode of sel.arguments) {
|
||||
const argKey = `${fieldPath}.${argNode.name.value}`;
|
||||
enabledArgs.add(argKey);
|
||||
|
||||
const argDef = fieldMap[fieldName].args.find((a) => a.name === argNode.name.value);
|
||||
const argNamed = argDef ? getNamedType(argDef.type) : null;
|
||||
|
||||
if (argNode.value.kind === Kind.VARIABLE) {
|
||||
const varName = argNode.value.name.value;
|
||||
const varValue = variablesJson[varName];
|
||||
if (argNamed && isInputObjectType(argNamed) && typeof varValue === 'object' && varValue !== null) {
|
||||
walkVariableInputObject(varValue, argNamed, argKey, argValues, enabledArgs);
|
||||
} else if (Array.isArray(varValue)) {
|
||||
argValues.set(argKey, varValue.map(String));
|
||||
} else if (varValue !== undefined && varValue !== null) {
|
||||
argValues.set(argKey, String(varValue));
|
||||
}
|
||||
} else if (argNamed && isInputObjectType(argNamed) && (argNode.value.kind === Kind.OBJECT || argNode.value.kind === Kind.LIST)) {
|
||||
walkInputObjectValue(argNode.value, argNamed, argKey, argValues, enabledArgs, variablesJson);
|
||||
} else if (argDef && containsListType(argDef.type) && argNode.value.kind === Kind.LIST) {
|
||||
// List-type scalar/enum args: store as array
|
||||
const items = argNode.value.values
|
||||
.map(astValueToString)
|
||||
.filter((v) => v !== null && v !== '');
|
||||
if (items.length > 0) {
|
||||
argValues.set(argKey, items);
|
||||
}
|
||||
} else {
|
||||
const value = astValueToString(argNode.value);
|
||||
if (value !== null && value !== '') {
|
||||
argValues.set(argKey, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sel.selectionSet && fieldMap && fieldMap[fieldName]) {
|
||||
expandedPaths.add(fieldPath);
|
||||
const named = getNamedType(fieldMap[fieldName].type);
|
||||
if (named) {
|
||||
walkSelectionSet(sel.selectionSet, named, fieldPath, selections, expandedPaths, argValues, enabledArgs, variablesJson, schema, depth + 1);
|
||||
}
|
||||
}
|
||||
} else if (sel.kind === Kind.INLINE_FRAGMENT) {
|
||||
const typeName = sel.typeCondition?.name?.value;
|
||||
if (typeName) {
|
||||
const memberPath = `${parentPath}.__on_${typeName}`;
|
||||
selections.add(memberPath);
|
||||
expandedPaths.add(memberPath);
|
||||
|
||||
// For unions, find the member type. For object/interface types with inline fragments, look up from schema.
|
||||
const named = getNamedType(parentType);
|
||||
const memberType = named?.getTypes?.()?.find((t) => t.name === typeName)
|
||||
|| schema.getType(typeName);
|
||||
if (memberType && sel.selectionSet) {
|
||||
walkSelectionSet(sel.selectionSet, memberType, memberPath, selections, expandedPaths, argValues, enabledArgs, variablesJson, schema, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const astValueToString = (valueNode) => {
|
||||
if (!valueNode) return null;
|
||||
switch (valueNode.kind) {
|
||||
case Kind.NULL:
|
||||
return '';
|
||||
case Kind.STRING:
|
||||
return valueNode.value;
|
||||
case Kind.INT:
|
||||
case Kind.FLOAT:
|
||||
return valueNode.value;
|
||||
case Kind.BOOLEAN:
|
||||
return String(valueNode.value);
|
||||
case Kind.ENUM:
|
||||
return valueNode.value;
|
||||
case Kind.LIST:
|
||||
return JSON.stringify(valueNode.values.map(astValueToString));
|
||||
case Kind.OBJECT: {
|
||||
const obj = {};
|
||||
for (const field of valueNode.fields) {
|
||||
obj[field.name.value] = astValueToString(field.value);
|
||||
}
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
664
packages/bruno-app/src/utils/graphql/queryBuilder.spec.js
Normal file
664
packages/bruno-app/src/utils/graphql/queryBuilder.spec.js
Normal file
@@ -0,0 +1,664 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
const { buildSchema } = require('graphql');
|
||||
|
||||
import {
|
||||
getAvailableRootTypes,
|
||||
getRootFields,
|
||||
getFieldChildren,
|
||||
getInputObjectFields,
|
||||
generateQueryString,
|
||||
validateQueryForSync,
|
||||
parseQueryToState
|
||||
} from './queryBuilder';
|
||||
|
||||
const BASIC_SCHEMA = buildSchema(`
|
||||
type Query {
|
||||
user(id: ID!): User
|
||||
users(limit: Int, offset: Int): [User!]!
|
||||
post(id: ID!): Post
|
||||
search(query: String!): SearchResult
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createUser(input: CreateUserInput!): User
|
||||
deleteUser(id: ID!): Boolean
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
name: String!
|
||||
email: String
|
||||
age: Int
|
||||
active: Boolean
|
||||
role: Role
|
||||
posts: [Post!]
|
||||
friends: [User!]
|
||||
}
|
||||
|
||||
type Post {
|
||||
id: ID!
|
||||
title: String!
|
||||
body: String
|
||||
author: User!
|
||||
comments: [Comment!]
|
||||
}
|
||||
|
||||
type Comment {
|
||||
id: ID!
|
||||
text: String!
|
||||
author: User!
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
USER
|
||||
MODERATOR
|
||||
}
|
||||
|
||||
union SearchResult = User | Post
|
||||
|
||||
input CreateUserInput {
|
||||
name: String!
|
||||
email: String!
|
||||
age: Int
|
||||
address: AddressInput
|
||||
}
|
||||
|
||||
input AddressInput {
|
||||
street: String
|
||||
city: String!
|
||||
zip: String
|
||||
}
|
||||
`);
|
||||
|
||||
describe('queryBuilder', () => {
|
||||
describe('getAvailableRootTypes', () => {
|
||||
it('should return available root types from schema', () => {
|
||||
const types = getAvailableRootTypes(BASIC_SCHEMA);
|
||||
expect(types).toEqual(['Query', 'Mutation']);
|
||||
});
|
||||
|
||||
it('should return empty array when schema is null', () => {
|
||||
expect(getAvailableRootTypes(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return only Query when no mutation exists', () => {
|
||||
const schema = buildSchema(`type Query { hello: String }`);
|
||||
expect(getAvailableRootTypes(schema)).toEqual(['Query']);
|
||||
});
|
||||
|
||||
it('should include Subscription when present', () => {
|
||||
const schema = buildSchema(`
|
||||
type Query { hello: String }
|
||||
type Subscription { onMessage: String }
|
||||
`);
|
||||
expect(getAvailableRootTypes(schema)).toEqual(['Query', 'Subscription']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRootFields', () => {
|
||||
it('should return all query fields', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const names = fields.map((f) => f.name);
|
||||
expect(names).toEqual(['user', 'users', 'post', 'search']);
|
||||
});
|
||||
|
||||
it('should return all mutation fields', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Mutation');
|
||||
const names = fields.map((f) => f.name);
|
||||
expect(names).toEqual(['createUser', 'deleteUser']);
|
||||
});
|
||||
|
||||
it('should return empty for non-existent root type', () => {
|
||||
expect(getRootFields(BASIC_SCHEMA, 'Subscription')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty when schema is null', () => {
|
||||
expect(getRootFields(null, 'Query')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should build correct field descriptors', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const userField = fields.find((f) => f.name === 'user');
|
||||
|
||||
expect(userField.path).toBe('Query.user');
|
||||
expect(userField.isLeaf).toBe(false);
|
||||
expect(userField.typeLabel).toBe('User');
|
||||
expect(userField.args).toHaveLength(1);
|
||||
expect(userField.args[0].name).toBe('id');
|
||||
expect(userField.args[0].typeLabel).toBe('ID!');
|
||||
expect(userField.args[0].isRequired).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify leaf fields correctly', () => {
|
||||
const schema = buildSchema(`type Query { name: String!, count: Int }`);
|
||||
const fields = getRootFields(schema, 'Query');
|
||||
expect(fields[0].isLeaf).toBe(true);
|
||||
expect(fields[1].isLeaf).toBe(true);
|
||||
});
|
||||
|
||||
it('should build descriptors with enum info', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const userField = fields.find((f) => f.name === 'user');
|
||||
const userChildren = getFieldChildren(userField.namedType, userField.path);
|
||||
const roleField = userChildren.fields.find((f) => f.name === 'role');
|
||||
// Role is an enum, so it's a leaf
|
||||
expect(roleField.isLeaf).toBe(true);
|
||||
});
|
||||
|
||||
it('should build args with isInputObject for input object args', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Mutation');
|
||||
const createUser = fields.find((f) => f.name === 'createUser');
|
||||
expect(createUser.args[0].name).toBe('input');
|
||||
expect(createUser.args[0].isInputObject).toBe(true);
|
||||
expect(createUser.args[0].typeLabel).toBe('CreateUserInput!');
|
||||
});
|
||||
|
||||
it('should build args with boolean detection', () => {
|
||||
const schema = buildSchema(`type Query { flag(active: Boolean): String }`);
|
||||
const fields = getRootFields(schema, 'Query');
|
||||
expect(fields[0].args[0].isBoolean).toBe(true);
|
||||
});
|
||||
|
||||
it('should build args with enum info', () => {
|
||||
const schema = buildSchema(`
|
||||
type Query { users(role: Role): [String] }
|
||||
enum Role { ADMIN USER }
|
||||
`);
|
||||
const fields = getRootFields(schema, 'Query');
|
||||
expect(fields[0].args[0].isEnum).toBe(true);
|
||||
expect(fields[0].args[0].enumValues).toEqual(['ADMIN', 'USER']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFieldChildren', () => {
|
||||
it('should return children of an object type', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const userField = fields.find((f) => f.name === 'user');
|
||||
const children = getFieldChildren(userField.namedType, 'Query.user');
|
||||
|
||||
expect(children.fields.length).toBeGreaterThan(0);
|
||||
const names = children.fields.map((f) => f.name);
|
||||
expect(names).toContain('id');
|
||||
expect(names).toContain('name');
|
||||
expect(names).toContain('email');
|
||||
expect(names).toContain('posts');
|
||||
expect(names).toContain('friends');
|
||||
});
|
||||
|
||||
it('should build correct paths for children', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const userField = fields.find((f) => f.name === 'user');
|
||||
const children = getFieldChildren(userField.namedType, 'Query.user');
|
||||
|
||||
const nameChild = children.fields.find((f) => f.name === 'name');
|
||||
expect(nameChild.path).toBe('Query.user.name');
|
||||
});
|
||||
|
||||
it('should return union types for union fields', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const searchField = fields.find((f) => f.name === 'search');
|
||||
const children = getFieldChildren(searchField.namedType, 'Query.search');
|
||||
|
||||
expect(children.fields).toEqual([]);
|
||||
expect(children.unionTypes).toHaveLength(2);
|
||||
expect(children.unionTypes[0].name).toBe('User');
|
||||
expect(children.unionTypes[0].path).toBe('Query.search.__on_User');
|
||||
expect(children.unionTypes[0].isUnionMember).toBe(true);
|
||||
expect(children.unionTypes[1].name).toBe('Post');
|
||||
expect(children.unionTypes[1].path).toBe('Query.search.__on_Post');
|
||||
});
|
||||
|
||||
it('should return empty array for null type', () => {
|
||||
expect(getFieldChildren(null, 'Query.foo')).toEqual({ fields: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInputObjectFields', () => {
|
||||
it('should return fields of an input object type', () => {
|
||||
const mutationFields = getRootFields(BASIC_SCHEMA, 'Mutation');
|
||||
const createUser = mutationFields.find((f) => f.name === 'createUser');
|
||||
const inputType = createUser.args[0].namedType;
|
||||
const fields = getInputObjectFields(inputType);
|
||||
|
||||
const names = fields.map((f) => f.name);
|
||||
expect(names).toEqual(['name', 'email', 'age', 'address']);
|
||||
});
|
||||
|
||||
it('should identify nested input object fields', () => {
|
||||
const mutationFields = getRootFields(BASIC_SCHEMA, 'Mutation');
|
||||
const createUser = mutationFields.find((f) => f.name === 'createUser');
|
||||
const inputType = createUser.args[0].namedType;
|
||||
const fields = getInputObjectFields(inputType);
|
||||
|
||||
const addressField = fields.find((f) => f.name === 'address');
|
||||
expect(addressField.isInputObject).toBe(true);
|
||||
expect(addressField.typeLabel).toBe('AddressInput');
|
||||
});
|
||||
|
||||
it('should identify required fields', () => {
|
||||
const mutationFields = getRootFields(BASIC_SCHEMA, 'Mutation');
|
||||
const createUser = mutationFields.find((f) => f.name === 'createUser');
|
||||
const inputType = createUser.args[0].namedType;
|
||||
const fields = getInputObjectFields(inputType);
|
||||
|
||||
expect(fields.find((f) => f.name === 'name').isRequired).toBe(true);
|
||||
expect(fields.find((f) => f.name === 'email').isRequired).toBe(true);
|
||||
expect(fields.find((f) => f.name === 'age').isRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('should return empty array for null type', () => {
|
||||
expect(getInputObjectFields(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty for non-input-object type', () => {
|
||||
const fields = getRootFields(BASIC_SCHEMA, 'Query');
|
||||
const userField = fields.find((f) => f.name === 'user');
|
||||
expect(getInputObjectFields(userField.namedType)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateQueryForSync', () => {
|
||||
it('should accept valid single named query', () => {
|
||||
expect(validateQueryForSync('query GetUser { user { id } }')).toEqual({ valid: true, error: null });
|
||||
});
|
||||
|
||||
it('should accept valid single named mutation', () => {
|
||||
expect(validateQueryForSync('mutation CreateUser { createUser { id } }')).toEqual({ valid: true, error: null });
|
||||
});
|
||||
|
||||
it('should accept empty/null query', () => {
|
||||
expect(validateQueryForSync('')).toEqual({ valid: true, error: null });
|
||||
expect(validateQueryForSync(null)).toEqual({ valid: true, error: null });
|
||||
expect(validateQueryForSync(' ')).toEqual({ valid: true, error: null });
|
||||
});
|
||||
|
||||
it('should reject multiple operations of the same type', () => {
|
||||
const result = validateQueryForSync('query A { user { id } } query B { post { id } }');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('multiple_operations');
|
||||
});
|
||||
|
||||
it('should reject mixed operation types (query + mutation)', () => {
|
||||
const result = validateQueryForSync('query A { user { id } } mutation B { createUser { id } }');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('multiple_operations');
|
||||
});
|
||||
|
||||
it('should reject mixed operation types (query + subscription)', () => {
|
||||
const result = validateQueryForSync('query A { user { id } } subscription B { onUserCreated { id } }');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('multiple_operations');
|
||||
});
|
||||
|
||||
it('should return valid false with no error for invalid syntax', () => {
|
||||
const result = validateQueryForSync('this is not graphql');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle queries with empty selection sets', () => {
|
||||
const result = validateQueryForSync('query Test { user {} }');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle queries with empty args', () => {
|
||||
const result = validateQueryForSync('query Test { user() { id } }');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateQueryString', () => {
|
||||
it('should return empty for no selections', () => {
|
||||
const result = generateQueryString(new Set(), new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
expect(result).toEqual({ query: '', variables: {} });
|
||||
});
|
||||
|
||||
it('should return empty for null schema', () => {
|
||||
const result = generateQueryString(new Set(['Query.user']), new Map(), null, 'Query', new Set());
|
||||
expect(result).toEqual({ query: '', variables: {} });
|
||||
});
|
||||
|
||||
it('should generate a simple query with leaf fields', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id', 'Query.user.name']);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
|
||||
expect(result.query).toContain('query');
|
||||
expect(result.query).toContain('user');
|
||||
expect(result.query).toContain('id');
|
||||
expect(result.query).toContain('name');
|
||||
expect(result.variables).toEqual({});
|
||||
});
|
||||
|
||||
it('should auto-generate operation name from first field', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id']);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
|
||||
expect(result.query).toMatch(/query User/);
|
||||
});
|
||||
|
||||
it('should use existing operation name when provided', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id']);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set(), 'MyCustomQuery');
|
||||
|
||||
expect(result.query).toMatch(/query MyCustomQuery/);
|
||||
});
|
||||
|
||||
it('should generate mutation operation type', () => {
|
||||
const selections = new Set(['Mutation.deleteUser']);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Mutation', new Set());
|
||||
|
||||
expect(result.query).toMatch(/mutation/);
|
||||
});
|
||||
|
||||
it('should generate variables for enabled args', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id', 'Query.user.name']);
|
||||
const enabledArgs = new Set(['Query.user.id']);
|
||||
const argValues = new Map([['Query.user.id', '123']]);
|
||||
const result = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
|
||||
expect(result.query).toContain('$id');
|
||||
expect(result.query).toContain('ID!');
|
||||
expect(result.query).toContain('id: $id');
|
||||
expect(result.variables.id).toBe('123');
|
||||
});
|
||||
|
||||
it('should coerce Int values', () => {
|
||||
const selections = new Set(['Query.users']);
|
||||
const enabledArgs = new Set(['Query.users.limit']);
|
||||
const argValues = new Map([['Query.users.limit', '10']]);
|
||||
const result = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
|
||||
expect(result.variables.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('should coerce Boolean values', () => {
|
||||
const schema = buildSchema(`type Query { flag(active: Boolean!): String }`);
|
||||
const selections = new Set(['Query.flag']);
|
||||
const enabledArgs = new Set(['Query.flag.active']);
|
||||
const argValues = new Map([['Query.flag.active', 'true']]);
|
||||
const result = generateQueryString(selections, argValues, schema, 'Query', enabledArgs);
|
||||
|
||||
expect(result.variables.active).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple selected fields', () => {
|
||||
const selections = new Set([
|
||||
'Query.user', 'Query.user.id', 'Query.user.name',
|
||||
'Query.post', 'Query.post.title'
|
||||
]);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
|
||||
expect(result.query).toContain('user');
|
||||
expect(result.query).toContain('post');
|
||||
expect(result.query).toContain('title');
|
||||
});
|
||||
|
||||
it('should handle nested non-leaf fields with __typename fallback', () => {
|
||||
// Select user.posts but no children of posts
|
||||
const selections = new Set(['Query.user', 'Query.user.posts']);
|
||||
const result = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
|
||||
// posts should still be in the query (with __typename fallback, which gets stripped)
|
||||
expect(result.query).toContain('posts');
|
||||
});
|
||||
|
||||
it('should handle union types with inline fragments', () => {
|
||||
const selections = new Set([
|
||||
'Query.search',
|
||||
'Query.search.__on_User',
|
||||
'Query.search.__on_User.name',
|
||||
'Query.search.__on_Post',
|
||||
'Query.search.__on_Post.title'
|
||||
]);
|
||||
const enabledArgs = new Set(['Query.search.query']);
|
||||
const argValues = new Map([['Query.search.query', 'test']]);
|
||||
const result = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
|
||||
expect(result.query).toContain('... on User');
|
||||
expect(result.query).toContain('... on Post');
|
||||
expect(result.query).toContain('name');
|
||||
expect(result.query).toContain('title');
|
||||
});
|
||||
|
||||
it('should disambiguate duplicate variable names', () => {
|
||||
const selections = new Set([
|
||||
'Query.user', 'Query.user.id',
|
||||
'Query.post', 'Query.post.id'
|
||||
]);
|
||||
const enabledArgs = new Set(['Query.user.id', 'Query.post.id']);
|
||||
const argValues = new Map([
|
||||
['Query.user.id', '1'],
|
||||
['Query.post.id', '2']
|
||||
]);
|
||||
const result = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
|
||||
// Both should have variables, and they should be disambiguated
|
||||
const varMatches = result.query.match(/\$\w+/g) || [];
|
||||
const uniqueVars = new Set(varMatches);
|
||||
expect(uniqueVars.size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should handle input object arguments with nested fields', () => {
|
||||
const selections = new Set(['Mutation.createUser', 'Mutation.createUser.id']);
|
||||
const enabledArgs = new Set([
|
||||
'Mutation.createUser.input',
|
||||
'Mutation.createUser.input.name',
|
||||
'Mutation.createUser.input.email'
|
||||
]);
|
||||
const argValues = new Map([
|
||||
['Mutation.createUser.input.name', 'Alice'],
|
||||
['Mutation.createUser.input.email', 'alice@test.com']
|
||||
]);
|
||||
const result = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Mutation', enabledArgs);
|
||||
|
||||
expect(result.query).toContain('input:');
|
||||
expect(result.query).toContain('$name');
|
||||
expect(result.query).toContain('$email');
|
||||
expect(result.variables.name).toBe('Alice');
|
||||
expect(result.variables.email).toBe('alice@test.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseQueryToState', () => {
|
||||
it('should return null for null schema', () => {
|
||||
expect(parseQueryToState('query Test { user { id } }', null)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return empty state for empty query', () => {
|
||||
const result = parseQueryToState('', BASIC_SCHEMA);
|
||||
expect(result.selections.size).toBe(0);
|
||||
expect(result.expandedPaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return null for unparseable query', () => {
|
||||
expect(parseQueryToState('this is not graphql', BASIC_SCHEMA)).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse a simple query', () => {
|
||||
const state = parseQueryToState('query GetUser { user { id name } }', BASIC_SCHEMA);
|
||||
|
||||
expect(state.selections.has('Query.user')).toBe(true);
|
||||
expect(state.selections.has('Query.user.id')).toBe(true);
|
||||
expect(state.selections.has('Query.user.name')).toBe(true);
|
||||
expect(state.expandedPaths.has('Query.user')).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse nested selections', () => {
|
||||
const state = parseQueryToState(`
|
||||
query GetUser {
|
||||
user {
|
||||
id
|
||||
posts {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
`, BASIC_SCHEMA);
|
||||
|
||||
expect(state.selections.has('Query.user')).toBe(true);
|
||||
expect(state.selections.has('Query.user.posts')).toBe(true);
|
||||
expect(state.selections.has('Query.user.posts.title')).toBe(true);
|
||||
expect(state.expandedPaths.has('Query.user')).toBe(true);
|
||||
expect(state.expandedPaths.has('Query.user.posts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse arguments with variable references', () => {
|
||||
const query = 'query GetUser($id: ID!) { user(id: $id) { name } }';
|
||||
const variables = JSON.stringify({ id: '123' });
|
||||
const state = parseQueryToState(query, BASIC_SCHEMA, variables);
|
||||
|
||||
expect(state.enabledArgs.has('Query.user.id')).toBe(true);
|
||||
expect(state.argValues.get('Query.user.id')).toBe('123');
|
||||
});
|
||||
|
||||
it('should parse inline argument values', () => {
|
||||
const state = parseQueryToState(`
|
||||
query GetUsers {
|
||||
users(limit: 10) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`, BASIC_SCHEMA);
|
||||
|
||||
expect(state.enabledArgs.has('Query.users.limit')).toBe(true);
|
||||
expect(state.argValues.get('Query.users.limit')).toBe('10');
|
||||
});
|
||||
|
||||
it('should parse mutation operations', () => {
|
||||
const state = parseQueryToState('mutation Delete { deleteUser(id: "1") }', BASIC_SCHEMA);
|
||||
expect(state.selections.has('Mutation.deleteUser')).toBe(true);
|
||||
expect(state.enabledArgs.has('Mutation.deleteUser.id')).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse inline fragments for union types', () => {
|
||||
const query = `
|
||||
query Search($query: String!) {
|
||||
search(query: $query) {
|
||||
... on User { name }
|
||||
... on Post { title }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const variables = JSON.stringify({ query: 'test' });
|
||||
const state = parseQueryToState(query, BASIC_SCHEMA, variables);
|
||||
|
||||
expect(state.selections.has('Query.search')).toBe(true);
|
||||
expect(state.selections.has('Query.search.__on_User')).toBe(true);
|
||||
expect(state.selections.has('Query.search.__on_User.name')).toBe(true);
|
||||
expect(state.selections.has('Query.search.__on_Post')).toBe(true);
|
||||
expect(state.selections.has('Query.search.__on_Post.title')).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse input object arguments', () => {
|
||||
const query = `
|
||||
mutation CreateUser($name: String!, $email: String!) {
|
||||
createUser(input: { name: $name, email: $email }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
const variables = JSON.stringify({ name: 'Alice', email: 'alice@test.com' });
|
||||
const state = parseQueryToState(query, BASIC_SCHEMA, variables);
|
||||
|
||||
expect(state.enabledArgs.has('Mutation.createUser.input')).toBe(true);
|
||||
expect(state.enabledArgs.has('Mutation.createUser.input.name')).toBe(true);
|
||||
expect(state.enabledArgs.has('Mutation.createUser.input.email')).toBe(true);
|
||||
expect(state.argValues.get('Mutation.createUser.input.name')).toBe('Alice');
|
||||
expect(state.argValues.get('Mutation.createUser.input.email')).toBe('alice@test.com');
|
||||
});
|
||||
|
||||
it('should handle empty selection sets', () => {
|
||||
const state = parseQueryToState('query Test { user {} }', BASIC_SCHEMA);
|
||||
expect(state).not.toBeNull();
|
||||
expect(state.selections.has('Query.user')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle queries with empty args', () => {
|
||||
const state = parseQueryToState('query Test { user() { id } }', BASIC_SCHEMA);
|
||||
expect(state).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should skip __typename fields', () => {
|
||||
const state = parseQueryToState('query Test { user { __typename id } }', BASIC_SCHEMA);
|
||||
expect(state.selections.has('Query.user.__typename')).toBe(false);
|
||||
expect(state.selections.has('Query.user.id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundtrip: generate then parse', () => {
|
||||
it('should roundtrip a simple query', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id', 'Query.user.name']);
|
||||
const generated = generateQueryString(selections, new Map(), BASIC_SCHEMA, 'Query', new Set());
|
||||
const parsed = parseQueryToState(generated.query, BASIC_SCHEMA);
|
||||
|
||||
expect(parsed.selections.has('Query.user')).toBe(true);
|
||||
expect(parsed.selections.has('Query.user.id')).toBe(true);
|
||||
expect(parsed.selections.has('Query.user.name')).toBe(true);
|
||||
});
|
||||
|
||||
it('should roundtrip a query with arguments', () => {
|
||||
const selections = new Set(['Query.user', 'Query.user.id', 'Query.user.name']);
|
||||
const enabledArgs = new Set(['Query.user.id']);
|
||||
const argValues = new Map([['Query.user.id', '42']]);
|
||||
const generated = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
|
||||
const varsJson = JSON.stringify(generated.variables);
|
||||
const parsed = parseQueryToState(generated.query, BASIC_SCHEMA, varsJson);
|
||||
|
||||
expect(parsed.selections.has('Query.user')).toBe(true);
|
||||
expect(parsed.enabledArgs.has('Query.user.id')).toBe(true);
|
||||
expect(parsed.argValues.get('Query.user.id')).toBe('42');
|
||||
});
|
||||
|
||||
it('should roundtrip a mutation with input object', () => {
|
||||
const selections = new Set(['Mutation.createUser', 'Mutation.createUser.id']);
|
||||
const enabledArgs = new Set([
|
||||
'Mutation.createUser.input',
|
||||
'Mutation.createUser.input.name',
|
||||
'Mutation.createUser.input.email',
|
||||
'Mutation.createUser.input.address',
|
||||
'Mutation.createUser.input.address.city'
|
||||
]);
|
||||
const argValues = new Map([
|
||||
['Mutation.createUser.input.name', 'Bob'],
|
||||
['Mutation.createUser.input.email', 'bob@test.com'],
|
||||
['Mutation.createUser.input.address.city', 'NYC']
|
||||
]);
|
||||
|
||||
const generated = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Mutation', enabledArgs);
|
||||
const varsJson = JSON.stringify(generated.variables);
|
||||
const parsed = parseQueryToState(generated.query, BASIC_SCHEMA, varsJson);
|
||||
|
||||
expect(parsed.enabledArgs.has('Mutation.createUser.input')).toBe(true);
|
||||
expect(parsed.enabledArgs.has('Mutation.createUser.input.name')).toBe(true);
|
||||
expect(parsed.enabledArgs.has('Mutation.createUser.input.email')).toBe(true);
|
||||
expect(parsed.enabledArgs.has('Mutation.createUser.input.address')).toBe(true);
|
||||
expect(parsed.enabledArgs.has('Mutation.createUser.input.address.city')).toBe(true);
|
||||
expect(parsed.argValues.get('Mutation.createUser.input.name')).toBe('Bob');
|
||||
expect(parsed.argValues.get('Mutation.createUser.input.email')).toBe('bob@test.com');
|
||||
expect(parsed.argValues.get('Mutation.createUser.input.address.city')).toBe('NYC');
|
||||
});
|
||||
|
||||
it('should roundtrip a query with union types', () => {
|
||||
const selections = new Set([
|
||||
'Query.search',
|
||||
'Query.search.__on_User',
|
||||
'Query.search.__on_User.name',
|
||||
'Query.search.__on_Post',
|
||||
'Query.search.__on_Post.title'
|
||||
]);
|
||||
const enabledArgs = new Set(['Query.search.query']);
|
||||
const argValues = new Map([['Query.search.query', 'hello']]);
|
||||
|
||||
const generated = generateQueryString(selections, argValues, BASIC_SCHEMA, 'Query', enabledArgs);
|
||||
const varsJson = JSON.stringify(generated.variables);
|
||||
const parsed = parseQueryToState(generated.query, BASIC_SCHEMA, varsJson);
|
||||
|
||||
expect(parsed.selections.has('Query.search.__on_User')).toBe(true);
|
||||
expect(parsed.selections.has('Query.search.__on_User.name')).toBe(true);
|
||||
expect(parsed.selections.has('Query.search.__on_Post')).toBe(true);
|
||||
expect(parsed.selections.has('Query.search.__on_Post.title')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user