Skip to content

Commit 3cbe6a6

Browse files
committed
feat(ai): add support for list variables
1 parent 09738e8 commit 3cbe6a6

File tree

2 files changed

+227
-12
lines changed

2 files changed

+227
-12
lines changed

packages/ai/src/mocking/GrowingSchema.ts

Lines changed: 160 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,25 +38,120 @@ const enforcedRules = specifiedRules.filter(
3838
(rule) => !rulesToIgnore.includes(rule)
3939
);
4040

41+
/**
42+
* Type guard for checking if an item is a single item.
43+
*
44+
* @param item - The item to check.
45+
* @returns True if the item is a single item, false otherwise.
46+
*/
4147
const isSingle = <T>(item: T | readonly T[]): item is T => !Array.isArray(item);
4248

49+
/**
50+
* Get the leaf type of a type node.
51+
*
52+
* @param typeNode - The type node to get the leaf type of.
53+
* @returns The leaf type of the type node.
54+
*/
4355
const getLeafType = (typeNode: TypeNode): NamedTypeNode => {
4456
return typeNode.kind === Kind.NAMED_TYPE ?
4557
typeNode
4658
: getLeafType(typeNode.type);
4759
};
4860

61+
/**
62+
* Convert the first letter of a string to uppercase.
63+
*
64+
* @param str - The string to convert.
65+
* @returns The string with the first letter capitalized.
66+
*/
4967
const ucFirst = (str: string) => {
5068
if (!str) {
5169
return "";
5270
}
5371
return str.charAt(0).toUpperCase() + str.slice(1);
5472
};
5573

74+
/**
75+
* Convert a plural word to its singular form.
76+
*
77+
* @param str - The plural word to convert.
78+
* @returns The singular form of the word.
79+
*/
80+
const singularize = (str: string) => {
81+
if (!str) {
82+
return "";
83+
}
84+
85+
// Handle common pluralization patterns
86+
if (str.endsWith("ies")) {
87+
return str.slice(0, -3) + "y";
88+
} else if (str.endsWith("ves")) {
89+
return str.slice(0, -3) + "f";
90+
} else if (str.endsWith("es")) {
91+
// Special cases for -es endings
92+
if (str.endsWith("ches") || str.endsWith("shes") || str.endsWith("xes")) {
93+
return str.slice(0, -2);
94+
} else if (str.endsWith("ses")) {
95+
return str.slice(0, -2);
96+
} else {
97+
return str.slice(0, -1);
98+
}
99+
} else if (str.endsWith("s") && str.length > 1) {
100+
return str.slice(0, -1);
101+
}
102+
103+
return str;
104+
};
105+
106+
/**
107+
* Check if a number is a float (i.e. 9.5).
108+
*
109+
* @param num - The number to check.
110+
* @returns True if the number is a float, false otherwise.
111+
*/
56112
function isFloat(num: number) {
57113
return typeof num === "number" && !Number.isInteger(num);
58114
}
59115

116+
/**
117+
* Deep merge utility function to preserve nested properties.
118+
*
119+
* @param target - The target object to merge into.
120+
* @param source - The source object to merge from.
121+
* @returns The merged object.
122+
*/
123+
function deepMerge(target: any, source: any): any {
124+
if (source === null || typeof source !== "object") {
125+
return source;
126+
}
127+
128+
if (Array.isArray(source)) {
129+
return source;
130+
}
131+
132+
if (target === null || typeof target !== "object" || Array.isArray(target)) {
133+
target = {};
134+
}
135+
136+
const result = { ...target };
137+
138+
for (const key in source) {
139+
if (source.hasOwnProperty(key)) {
140+
if (
141+
typeof source[key] === "object" &&
142+
source[key] !== null &&
143+
!Array.isArray(source[key])
144+
) {
145+
result[key] = deepMerge(result[key], source[key]);
146+
} else {
147+
result[key] = source[key];
148+
}
149+
}
150+
}
151+
152+
return result;
153+
}
154+
60155
const ScalarTypes = ["String", "Int", "Float", "Boolean", "ID"];
61156

62157
export type OperationVariableDefinitions = Record<string, TypeNode>;
@@ -183,7 +278,7 @@ export class GrowingSchema {
183278
// Create all input objects from the operation's variable definitions.
184279
// By doing this here, we _may_ create unused input objects, but this
185280
// helps us avoid complexity in tying input objects to field definitions.
186-
const inputObjects =
281+
const inputObjects = this.mergeRepeatedInputObjects(
187282
variableDefinitions.reduce(
188283
(acc, variableDefinition) => {
189284
const leafType = getLeafType(variableDefinition.type);
@@ -208,7 +303,8 @@ export class GrowingSchema {
208303
return acc;
209304
},
210305
[] as (InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode)[]
211-
) || [];
306+
) || []
307+
);
212308

213309
accumulatedExtensions.definitions.push(...inputObjects);
214310

@@ -538,7 +634,15 @@ export class GrowingSchema {
538634
inputObjects: InputObjectsList;
539635
} {
540636
const inputObjects: InputObjectsList = [];
541-
const fields = Object.entries(valuesInScope)
637+
638+
let valuesToHandle = valuesInScope;
639+
if (Array.isArray(valuesInScope)) {
640+
valuesToHandle = valuesInScope.reduce((acc, item) => {
641+
return deepMerge(acc, item);
642+
}, {});
643+
}
644+
645+
const fields = Object.entries(valuesToHandle)
542646
.map(([fieldName, fieldVariableValue]) => {
543647
let valueType: TypeNode;
544648
switch (typeof fieldVariableValue) {
@@ -549,19 +653,40 @@ export class GrowingSchema {
549653
name: { kind: Kind.NAME, value: "String" },
550654
};
551655
} else {
552-
// If a variable field is a key/value object, then it is
553-
// an input object and we need to create it and any other
554-
// input objects from its fields.
555-
const inputObjectName = `${ucFirst(fieldName)}Input`;
556-
const inputObject = this.getInputObjectsForVariableValue(
557-
inputObjectName,
558-
fieldVariableValue
559-
);
560-
inputObjects.push(...inputObject);
656+
// Create a name for the input object based on the singular
657+
// form of the field name + "Input".
658+
const inputObjectName = `${ucFirst(singularize(fieldName))}Input`;
659+
660+
// Create a type node for the input object.
561661
valueType = {
562662
kind: Kind.NAMED_TYPE,
563663
name: { kind: Kind.NAME, value: inputObjectName },
564664
};
665+
666+
// If the field value is an array, then we need to create a list
667+
// type node for the input object and merge the array items
668+
// into a single object for creating the input object.
669+
let variableValueToHandle = fieldVariableValue;
670+
if (Array.isArray(fieldVariableValue)) {
671+
valueType = {
672+
kind: Kind.LIST_TYPE,
673+
type: valueType,
674+
};
675+
variableValueToHandle = fieldVariableValue.reduce(
676+
(acc, item) => {
677+
return deepMerge(acc, item);
678+
},
679+
{}
680+
);
681+
}
682+
683+
// Create the input object and any other input objects from its
684+
// fields.
685+
const inputObject = this.getInputObjectsForVariableValue(
686+
inputObjectName,
687+
variableValueToHandle
688+
);
689+
inputObjects.push(...inputObject);
565690
}
566691
break;
567692
case "string":
@@ -608,6 +733,29 @@ export class GrowingSchema {
608733
return { fields, inputObjects };
609734
}
610735

736+
mergeRepeatedInputObjects(inputObjects: InputObjectsList): InputObjectsList {
737+
return Object.values(
738+
inputObjects.reduce(
739+
(acc, inputObject) => {
740+
const existingInputObject = acc[inputObject.name.value];
741+
if (existingInputObject) {
742+
acc[inputObject.name.value] = {
743+
...existingInputObject,
744+
fields: [
745+
...(existingInputObject?.fields || []),
746+
...(inputObject.fields || []),
747+
],
748+
};
749+
} else {
750+
acc[inputObject.name.value] = inputObject;
751+
}
752+
return acc;
753+
},
754+
{} as Record<string, InputObject>
755+
)
756+
);
757+
}
758+
611759
public toString() {
612760
return printSchema(this.schema);
613761
}

packages/ai/src/mocking/__tests__/GrowingSchema.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,73 @@ describe("GrowingSchema", () => {
556556
expect(schema.toString()).toEqualIgnoringWhitespace(secondExpectedSchema);
557557
});
558558

559+
it("handles list variables", () => {
560+
const query = gql`
561+
query SearchByAuthor($authors: [AuthorInput!]!) {
562+
bookByAuthor(authors: $authors) {
563+
__typename
564+
title
565+
}
566+
}
567+
`;
568+
const variables = {
569+
authors: [
570+
{
571+
name: {
572+
nickNames: [
573+
{
574+
full: "The Doctor",
575+
},
576+
],
577+
},
578+
},
579+
{
580+
name: {
581+
firstName: "Sarah",
582+
middleName: "Jane",
583+
lastName: "Smith",
584+
},
585+
},
586+
],
587+
};
588+
const response = {
589+
data: {
590+
bookByAuthor: {
591+
__typename: "Book",
592+
title: "The Tardis",
593+
},
594+
},
595+
};
596+
const expectedSchema = /* GraphQL */ `
597+
type Query {
598+
bookByAuthor(authors: [AuthorInput!]!): Book
599+
}
600+
601+
input AuthorInput {
602+
name: NameInput
603+
}
604+
605+
input NameInput {
606+
nickNames: [NickNameInput]
607+
firstName: String
608+
middleName: String
609+
lastName: String
610+
}
611+
612+
input NickNameInput {
613+
full: String
614+
}
615+
616+
type Book {
617+
title: String
618+
}
619+
`;
620+
621+
const schema = new GrowingSchema();
622+
schema.add({ query, variables }, response);
623+
expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema);
624+
});
625+
559626
it.skip("handles union types with inline fragments", () => {
560627
const query = gql`
561628
query Search {

0 commit comments

Comments
 (0)