@wundergraph/protographic 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,7 @@ Protographic bridges GraphQL and Protocol Buffers (protobuf) ecosystems through
18
18
  - Robust handling of complex GraphQL features (unions, interfaces, directives)
19
19
  - First-class support for Federation entity mapping
20
20
  - Deterministic field ordering with proto.lock.json for backward compatibility
21
+ - Use of Protocol Buffer wrappers for nullable fields to distinguish between semantic nulls and zero values
21
22
 
22
23
  ## Installation
23
24
 
@@ -31,10 +31,16 @@ export declare class GraphQLToProtoTextVisitor {
31
31
  private readonly schema;
32
32
  /** The name of the Protocol Buffer service */
33
33
  private readonly serviceName;
34
+ /** The package name for the proto file */
35
+ private readonly packageName;
34
36
  /** The lock manager for deterministic ordering */
35
37
  private readonly lockManager;
36
38
  /** Generated proto lock data */
37
39
  private generatedLockData;
40
+ /** List of import statements */
41
+ private imports;
42
+ /** List of option statements */
43
+ private options;
38
44
  /** Accumulates the Protocol Buffer definition text */
39
45
  private protoText;
40
46
  /** Current indentation level for formatted output */
@@ -47,6 +53,8 @@ export declare class GraphQLToProtoTextVisitor {
47
53
  private messageQueue;
48
54
  /** Track generated nested list wrapper messages */
49
55
  private nestedListWrappers;
56
+ /** Track whether wrapper types are used (for conditional import) */
57
+ private usesWrapperTypes;
50
58
  /**
51
59
  * Map of message names to their field numbers for tracking deleted fields
52
60
  * This maintains field numbers even when fields are removed from the schema
@@ -93,6 +101,18 @@ export declare class GraphQLToProtoTextVisitor {
93
101
  * removed from the schema but may be re-added in the future.
94
102
  */
95
103
  private getNextAvailableFieldNumber;
104
+ /**
105
+ * Add an import statement to the proto file
106
+ */
107
+ private addImport;
108
+ /**
109
+ * Add an option statement to the proto file
110
+ */
111
+ private addOption;
112
+ /**
113
+ * Build the proto file header with syntax, package, imports, and options
114
+ */
115
+ private buildProtoHeader;
96
116
  /**
97
117
  * Visit the GraphQL schema to generate Proto buffer definition
98
118
  *
@@ -234,16 +254,72 @@ export declare class GraphQLToProtoTextVisitor {
234
254
  * Map GraphQL type to Protocol Buffer type
235
255
  *
236
256
  * Determines the appropriate Protocol Buffer type for a given GraphQL type,
237
- * handling all GraphQL type wrappers (NonNull, List) correctly.
257
+ * including the use of wrapper types for nullable scalar fields to distinguish
258
+ * between unset fields and zero values.
238
259
  *
239
260
  * @param graphqlType - The GraphQL type to convert
261
+ * @param ignoreWrapperTypes - If true, do not use wrapper types for nullable scalar fields
240
262
  * @returns The corresponding Protocol Buffer type name
241
263
  */
242
264
  private getProtoTypeFromGraphQL;
243
265
  /**
244
- * Create a nested list wrapper message for the given base type
266
+ * Converts GraphQL list types to appropriate Protocol Buffer representations.
267
+ *
268
+ * For non-nullable, single-level lists (e.g., [String!]!), generates simple repeated fields.
269
+ * For nullable lists (e.g., [String]) or nested lists (e.g., [[String]]), creates wrapper
270
+ * messages to properly handle nullability in proto3.
271
+ *
272
+ * Examples:
273
+ * - [String!]! → repeated string field_name = 1;
274
+ * - [String] → ListOfString field_name = 1; (with wrapper message)
275
+ * - [[String!]!]! → ListOfListOfString field_name = 1; (with nested wrapper messages)
276
+ * - [[String]] → ListOfListOfString field_name = 1; (with nested wrapper messages)
277
+ *
278
+ * @param graphqlType - The GraphQL list type to convert
279
+ * @returns ProtoType object containing the type name and whether it should be repeated
280
+ */
281
+ private handleListType;
282
+ /**
283
+ * Unwraps a GraphQL type from a GraphQLNonNull type
284
+ */
285
+ private unwrapNonNullType;
286
+ /**
287
+ * Checks if a GraphQL list type contains nested lists
288
+ * Type guard that narrows the input type when nested lists are detected
289
+ */
290
+ private isNestedListType;
291
+ /**
292
+ * Calculates the nesting level of a GraphQL list type
293
+ */
294
+ private calculateNestingLevel;
295
+ /**
296
+ * Creates wrapper messages for nullable or nested GraphQL lists.
297
+ *
298
+ * Generates Protocol Buffer message definitions to handle list nullability and nesting.
299
+ * The wrapper messages are stored and later included in the final proto output.
300
+ *
301
+ * For level 1: Creates simple wrapper like:
302
+ * message ListOfString {
303
+ * repeated string items = 1;
304
+ * }
305
+ *
306
+ * For level > 1: Creates nested wrapper structures like:
307
+ * message ListOfListOfString {
308
+ * message List {
309
+ * repeated ListOfString items = 1;
310
+ * }
311
+ * List list = 1;
312
+ * }
313
+ *
314
+ * @param level - The nesting level (1 for simple wrapper, >1 for nested structures)
315
+ * @param baseType - The GraphQL base type being wrapped (e.g., String, User, etc.)
316
+ * @returns The generated wrapper message name (e.g., "ListOfString", "ListOfListOfUser")
245
317
  */
246
318
  private createNestedListWrapper;
319
+ /**
320
+ * Builds the message lines for a wrapper message
321
+ */
322
+ private buildWrapperMessage;
247
323
  /**
248
324
  * Get indentation based on the current level
249
325
  *
@@ -15,6 +15,19 @@ const SCALAR_TYPE_MAP = {
15
15
  Float: 'double', // Using double for GraphQL Float gives better precision
16
16
  Boolean: 'bool', // Direct mapping
17
17
  };
18
+ /**
19
+ * Maps GraphQL scalar types to Protocol Buffer wrapper types for nullable fields
20
+ *
21
+ * These wrapper types allow distinguishing between unset fields and zero values
22
+ * in Protocol Buffers, which is important for GraphQL nullable semantics.
23
+ */
24
+ const SCALAR_WRAPPER_TYPE_MAP = {
25
+ ID: 'google.protobuf.StringValue',
26
+ String: 'google.protobuf.StringValue',
27
+ Int: 'google.protobuf.Int32Value',
28
+ Float: 'google.protobuf.DoubleValue',
29
+ Boolean: 'google.protobuf.BoolValue',
30
+ };
18
31
  /**
19
32
  * Visitor that converts GraphQL SDL to Protocol Buffer text definition
20
33
  *
@@ -40,6 +53,10 @@ export class GraphQLToProtoTextVisitor {
40
53
  constructor(schema, options = {}) {
41
54
  /** Generated proto lock data */
42
55
  this.generatedLockData = null;
56
+ /** List of import statements */
57
+ this.imports = [];
58
+ /** List of option statements */
59
+ this.options = [];
43
60
  /** Accumulates the Protocol Buffer definition text */
44
61
  this.protoText = [];
45
62
  /** Current indentation level for formatted output */
@@ -50,6 +67,8 @@ export class GraphQLToProtoTextVisitor {
50
67
  this.messageQueue = [];
51
68
  /** Track generated nested list wrapper messages */
52
69
  this.nestedListWrappers = new Map();
70
+ /** Track whether wrapper types are used (for conditional import) */
71
+ this.usesWrapperTypes = false;
53
72
  /**
54
73
  * Map of message names to their field numbers for tracking deleted fields
55
74
  * This maintains field numbers even when fields are removed from the schema
@@ -58,21 +77,20 @@ export class GraphQLToProtoTextVisitor {
58
77
  const { serviceName = 'DefaultService', packageName = 'service.v1', goPackage, lockData, includeComments = true, } = options;
59
78
  this.schema = schema;
60
79
  this.serviceName = serviceName;
80
+ this.packageName = packageName;
61
81
  this.lockManager = new ProtoLockManager(lockData);
62
82
  this.includeComments = includeComments;
63
83
  // If we have lock data, initialize the field numbers map
64
84
  if (lockData) {
65
85
  this.initializeFieldNumbersMap(lockData);
66
86
  }
67
- const protoOptions = [];
87
+ // Initialize options
68
88
  if (goPackage && goPackage !== '') {
69
89
  // Generate default go_package if not provided
70
90
  const defaultGoPackage = `cosmo/pkg/proto/${packageName};${packageName.replace('.', '')}`;
71
91
  const goPackageOption = goPackage || defaultGoPackage;
72
- protoOptions.push(`option go_package = "${goPackageOption}";\n`);
92
+ this.options.push(`option go_package = "${goPackageOption}";`);
73
93
  }
74
- // Initialize the Proto definition with the standard header
75
- this.protoText = ['syntax = "proto3";', `package ${packageName};`, '', ...protoOptions];
76
94
  }
77
95
  /**
78
96
  * Initialize the field numbers map from the lock data to preserve field numbers
@@ -229,15 +247,58 @@ export class GraphQLToProtoTextVisitor {
229
247
  // Find the maximum field number and add 1
230
248
  return Math.max(...usedNumbers) + 1;
231
249
  }
250
+ /**
251
+ * Add an import statement to the proto file
252
+ */
253
+ addImport(importPath) {
254
+ if (!this.imports.includes(importPath)) {
255
+ this.imports.push(importPath);
256
+ }
257
+ }
258
+ /**
259
+ * Add an option statement to the proto file
260
+ */
261
+ addOption(optionStatement) {
262
+ if (!this.options.includes(optionStatement)) {
263
+ this.options.push(optionStatement);
264
+ }
265
+ }
266
+ /**
267
+ * Build the proto file header with syntax, package, imports, and options
268
+ */
269
+ buildProtoHeader() {
270
+ const header = [];
271
+ // Add syntax declaration
272
+ header.push('syntax = "proto3";');
273
+ // Add package declaration
274
+ header.push(`package ${this.packageName};`);
275
+ header.push('');
276
+ // Add options if any (options come before imports)
277
+ if (this.options.length > 0) {
278
+ // Sort options for consistent output
279
+ const sortedOptions = [...this.options].sort();
280
+ for (const option of sortedOptions) {
281
+ header.push(option);
282
+ }
283
+ header.push('');
284
+ }
285
+ // Add imports if any
286
+ if (this.imports.length > 0) {
287
+ // Sort imports for consistent output
288
+ const sortedImports = [...this.imports].sort();
289
+ for (const importPath of sortedImports) {
290
+ header.push(`import "${importPath}";`);
291
+ }
292
+ header.push('');
293
+ }
294
+ return header;
295
+ }
232
296
  /**
233
297
  * Visit the GraphQL schema to generate Proto buffer definition
234
298
  *
235
299
  * @returns The complete Protocol Buffer definition as a string
236
300
  */
237
301
  visit() {
238
- // Clear the protoText array to just contain the header
239
- const headerText = this.protoText.slice();
240
- this.protoText = [];
241
302
  // Collect RPC methods and message definitions from all sources
242
303
  const entityResult = this.collectEntityRpcMethods();
243
304
  const queryResult = this.collectQueryRpcMethods();
@@ -252,15 +313,23 @@ export class GraphQLToProtoTextVisitor {
252
313
  ];
253
314
  // Add all types from the schema to the queue that weren't already queued
254
315
  this.queueAllSchemaTypes();
255
- // Start with the header
256
- this.protoText = headerText;
316
+ // Process all complex types from the message queue to determine if wrapper types are needed
317
+ this.processMessageQueue();
318
+ // Add wrapper import if needed
319
+ if (this.usesWrapperTypes) {
320
+ this.addImport('google/protobuf/wrappers.proto');
321
+ }
322
+ // Build the complete proto file
323
+ const protoContent = [];
324
+ // Add the header (syntax, package, imports, options)
325
+ protoContent.push(...this.buildProtoHeader());
257
326
  // Add a service description comment
258
327
  if (this.includeComments) {
259
328
  const serviceComment = `Service definition for ${this.serviceName}`;
260
- this.protoText.push(...this.formatComment(serviceComment, 0)); // Top-level comment, no indent
329
+ protoContent.push(...this.formatComment(serviceComment, 0)); // Top-level comment, no indent
261
330
  }
262
- // First: Create service block containing only RPC methods
263
- this.protoText.push(`service ${this.serviceName} {`);
331
+ // Add service block containing RPC methods
332
+ protoContent.push(`service ${this.serviceName} {`);
264
333
  this.indent++;
265
334
  // Sort method names deterministically by alphabetical order
266
335
  const orderedMethodNames = [...allMethodNames].sort();
@@ -273,35 +342,35 @@ export class GraphQLToProtoTextVisitor {
273
342
  if (rpcMethodText.includes('\n')) {
274
343
  // For multi-line RPC method definitions (with comments), add each line separately
275
344
  const lines = rpcMethodText.split('\n');
276
- this.protoText.push(...lines);
345
+ protoContent.push(...lines);
277
346
  }
278
347
  else {
279
348
  // For simple one-line RPC method definitions (ensure 2-space indentation)
280
- this.protoText.push(` ${rpcMethodText}`);
349
+ protoContent.push(` ${rpcMethodText}`);
281
350
  }
282
351
  }
283
352
  }
284
353
  // Close service definition
285
354
  this.indent--;
286
- this.protoText.push('}');
287
- this.protoText.push('');
355
+ protoContent.push('}');
356
+ protoContent.push('');
288
357
  // Add all wrapper messages first since they might be referenced by other messages
289
358
  if (this.nestedListWrappers.size > 0) {
290
359
  // Sort the wrappers by name for deterministic output
291
360
  const sortedWrapperNames = Array.from(this.nestedListWrappers.keys()).sort();
292
361
  for (const wrapperName of sortedWrapperNames) {
293
- this.protoText.push(this.nestedListWrappers.get(wrapperName));
362
+ protoContent.push(this.nestedListWrappers.get(wrapperName));
294
363
  }
295
364
  }
296
- // Second: Add all message definitions
365
+ // Add all message definitions
297
366
  for (const messageDef of allMessageDefinitions) {
298
- this.protoText.push(messageDef);
367
+ protoContent.push(messageDef);
299
368
  }
300
- // Third: Process all complex types from the message queue in a single pass
301
- this.processMessageQueue();
369
+ // Add all processed types from protoText (populated by processMessageQueue)
370
+ protoContent.push(...this.protoText);
302
371
  // Store the generated lock data for retrieval
303
372
  this.generatedLockData = this.lockManager.getLockData();
304
- return this.protoText.join('\n');
373
+ return protoContent.join('\n');
305
374
  }
306
375
  /**
307
376
  * Collects RPC methods for entity types (types with @key directive)
@@ -597,12 +666,11 @@ Example:
597
666
  messageLines.push(...this.formatComment(arg.description, 1));
598
667
  }
599
668
  // Check if the argument is a list type and add the repeated keyword if needed
600
- const isRepeated = isListType(arg.type) || (isNonNullType(arg.type) && isListType(arg.type.ofType));
601
- if (isRepeated) {
602
- messageLines.push(` repeated ${argType} ${argProtoName} = ${fieldNumber};`);
669
+ if (argType.isRepeated) {
670
+ messageLines.push(` repeated ${argType.typeName} ${argProtoName} = ${fieldNumber};`);
603
671
  }
604
672
  else {
605
- messageLines.push(` ${argType} ${argProtoName} = ${fieldNumber};`);
673
+ messageLines.push(` ${argType.typeName} ${argProtoName} = ${fieldNumber};`);
606
674
  }
607
675
  // Add complex input types to the queue for processing
608
676
  const namedType = getNamedType(arg.type);
@@ -645,7 +713,6 @@ Example:
645
713
  messageLines.push(` reserved ${this.formatReservedNumbers(messageLock.reservedNumbers)};`);
646
714
  }
647
715
  const returnType = this.getProtoTypeFromGraphQL(field.type);
648
- const isRepeated = isListType(field.type) || (isNonNullType(field.type) && isListType(field.type.ofType));
649
716
  // Get the appropriate field number, respecting the lock
650
717
  const fieldNumber = this.getFieldNumber(responseName, protoFieldName, 1);
651
718
  // Add description for the response field based on field description
@@ -653,11 +720,11 @@ Example:
653
720
  // Use 1 level indent for field comments
654
721
  messageLines.push(...this.formatComment(field.description, 1));
655
722
  }
656
- if (isRepeated) {
657
- messageLines.push(` repeated ${returnType} ${protoFieldName} = ${fieldNumber};`);
723
+ if (returnType.isRepeated) {
724
+ messageLines.push(` repeated ${returnType.typeName} ${protoFieldName} = ${fieldNumber};`);
658
725
  }
659
726
  else {
660
- messageLines.push(` ${returnType} ${protoFieldName} = ${fieldNumber};`);
727
+ messageLines.push(` ${returnType.typeName} ${protoFieldName} = ${fieldNumber};`);
661
728
  }
662
729
  messageLines.push('}');
663
730
  // Ensure this message is registered in the lock manager data
@@ -788,7 +855,6 @@ Example:
788
855
  continue;
789
856
  const field = fields[fieldName];
790
857
  const fieldType = this.getProtoTypeFromGraphQL(field.type);
791
- const isRepeated = isListType(field.type) || (isNonNullType(field.type) && isListType(field.type.ofType));
792
858
  const protoFieldName = graphqlFieldToProtoField(fieldName);
793
859
  // Get the appropriate field number, respecting the lock
794
860
  const fieldNumber = this.getFieldNumber(type.name, protoFieldName, this.getNextAvailableFieldNumber(type.name));
@@ -796,11 +862,11 @@ Example:
796
862
  if (field.description) {
797
863
  this.protoText.push(...this.formatComment(field.description, 1)); // Field comment, indent 1 level
798
864
  }
799
- if (isRepeated) {
800
- this.protoText.push(` repeated ${fieldType} ${protoFieldName} = ${fieldNumber};`);
865
+ if (fieldType.isRepeated) {
866
+ this.protoText.push(` repeated ${fieldType.typeName} ${protoFieldName} = ${fieldNumber};`);
801
867
  }
802
868
  else {
803
- this.protoText.push(` ${fieldType} ${protoFieldName} = ${fieldNumber};`);
869
+ this.protoText.push(` ${fieldType.typeName} ${protoFieldName} = ${fieldNumber};`);
804
870
  }
805
871
  // Queue complex field types for processing
806
872
  const namedType = getNamedType(field.type);
@@ -848,7 +914,6 @@ Example:
848
914
  continue;
849
915
  const field = fields[fieldName];
850
916
  const fieldType = this.getProtoTypeFromGraphQL(field.type);
851
- const isRepeated = isListType(field.type) || (isNonNullType(field.type) && isListType(field.type.ofType));
852
917
  const protoFieldName = graphqlFieldToProtoField(fieldName);
853
918
  // Get the appropriate field number, respecting the lock
854
919
  const fieldNumber = this.getFieldNumber(type.name, protoFieldName, this.getNextAvailableFieldNumber(type.name));
@@ -856,11 +921,11 @@ Example:
856
921
  if (field.description) {
857
922
  this.protoText.push(...this.formatComment(field.description, 1)); // Field comment, indent 1 level
858
923
  }
859
- if (isRepeated) {
860
- this.protoText.push(` repeated ${fieldType} ${protoFieldName} = ${fieldNumber};`);
924
+ if (fieldType.isRepeated) {
925
+ this.protoText.push(` repeated ${fieldType.typeName} ${protoFieldName} = ${fieldNumber};`);
861
926
  }
862
927
  else {
863
- this.protoText.push(` ${fieldType} ${protoFieldName} = ${fieldNumber};`);
928
+ this.protoText.push(` ${fieldType.typeName} ${protoFieldName} = ${fieldNumber};`);
864
929
  }
865
930
  // Queue complex field types for processing
866
931
  const namedType = getNamedType(field.type);
@@ -1036,90 +1101,178 @@ Example:
1036
1101
  * Map GraphQL type to Protocol Buffer type
1037
1102
  *
1038
1103
  * Determines the appropriate Protocol Buffer type for a given GraphQL type,
1039
- * handling all GraphQL type wrappers (NonNull, List) correctly.
1104
+ * including the use of wrapper types for nullable scalar fields to distinguish
1105
+ * between unset fields and zero values.
1040
1106
  *
1041
1107
  * @param graphqlType - The GraphQL type to convert
1108
+ * @param ignoreWrapperTypes - If true, do not use wrapper types for nullable scalar fields
1042
1109
  * @returns The corresponding Protocol Buffer type name
1043
1110
  */
1044
- getProtoTypeFromGraphQL(graphqlType) {
1111
+ getProtoTypeFromGraphQL(graphqlType, ignoreWrapperTypes = false) {
1112
+ // Nullable lists need to be handled first, otherwise they will be treated as scalar types
1113
+ if (isListType(graphqlType) || (isNonNullType(graphqlType) && isListType(graphqlType.ofType))) {
1114
+ return this.handleListType(graphqlType);
1115
+ }
1116
+ // For nullable scalar types, use wrapper types
1045
1117
  if (isScalarType(graphqlType)) {
1046
- return SCALAR_TYPE_MAP[graphqlType.name] || 'string';
1118
+ if (ignoreWrapperTypes) {
1119
+ return { typeName: SCALAR_TYPE_MAP[graphqlType.name] || 'string', isRepeated: false };
1120
+ }
1121
+ this.usesWrapperTypes = true; // Track that we're using wrapper types
1122
+ return {
1123
+ typeName: SCALAR_WRAPPER_TYPE_MAP[graphqlType.name] || 'google.protobuf.StringValue',
1124
+ isRepeated: false,
1125
+ };
1047
1126
  }
1048
1127
  if (isEnumType(graphqlType)) {
1049
- return graphqlType.name;
1128
+ return { typeName: graphqlType.name, isRepeated: false };
1050
1129
  }
1051
1130
  if (isNonNullType(graphqlType)) {
1052
- return this.getProtoTypeFromGraphQL(graphqlType.ofType);
1053
- }
1054
- if (isListType(graphqlType)) {
1055
- // Handle nested list types (e.g., [[Type]])
1056
- const innerType = graphqlType.ofType;
1057
- // If the inner type is also a list, we need to use a wrapper message
1058
- if (isListType(innerType) || (isNonNullType(innerType) && isListType(innerType.ofType))) {
1059
- // Find the most inner type by unwrapping all lists and non-nulls
1060
- let currentType = innerType;
1061
- while (isListType(currentType) || isNonNullType(currentType)) {
1062
- currentType = isListType(currentType) ? currentType.ofType : currentType.ofType;
1063
- }
1064
- // Get the name of the inner type and create wrapper name
1065
- const namedInnerType = currentType;
1066
- const wrapperName = `${namedInnerType.name}List`;
1067
- // Generate the wrapper message if not already created
1068
- if (!this.processedTypes.has(wrapperName) && !this.nestedListWrappers.has(wrapperName)) {
1069
- this.createNestedListWrapper(wrapperName, namedInnerType);
1070
- }
1071
- return wrapperName;
1131
+ // For non-null scalar types, use the base type
1132
+ if (isScalarType(graphqlType.ofType)) {
1133
+ return { typeName: SCALAR_TYPE_MAP[graphqlType.ofType.name] || 'string', isRepeated: false };
1072
1134
  }
1073
- return this.getProtoTypeFromGraphQL(innerType);
1135
+ return this.getProtoTypeFromGraphQL(graphqlType.ofType);
1074
1136
  }
1075
1137
  // Named types (object, interface, union, input)
1076
1138
  const namedType = graphqlType;
1077
1139
  if (namedType && typeof namedType.name === 'string') {
1078
- return namedType.name;
1140
+ return { typeName: namedType.name, isRepeated: false };
1079
1141
  }
1080
- return 'string'; // Default fallback
1142
+ return { typeName: 'string', isRepeated: false }; // Default fallback
1143
+ }
1144
+ /**
1145
+ * Converts GraphQL list types to appropriate Protocol Buffer representations.
1146
+ *
1147
+ * For non-nullable, single-level lists (e.g., [String!]!), generates simple repeated fields.
1148
+ * For nullable lists (e.g., [String]) or nested lists (e.g., [[String]]), creates wrapper
1149
+ * messages to properly handle nullability in proto3.
1150
+ *
1151
+ * Examples:
1152
+ * - [String!]! → repeated string field_name = 1;
1153
+ * - [String] → ListOfString field_name = 1; (with wrapper message)
1154
+ * - [[String!]!]! → ListOfListOfString field_name = 1; (with nested wrapper messages)
1155
+ * - [[String]] → ListOfListOfString field_name = 1; (with nested wrapper messages)
1156
+ *
1157
+ * @param graphqlType - The GraphQL list type to convert
1158
+ * @returns ProtoType object containing the type name and whether it should be repeated
1159
+ */
1160
+ handleListType(graphqlType) {
1161
+ const listType = this.unwrapNonNullType(graphqlType);
1162
+ const isNullableList = !isNonNullType(graphqlType);
1163
+ const isNestedList = this.isNestedListType(listType);
1164
+ // Simple non-nullable lists can use repeated fields directly
1165
+ if (!isNullableList && !isNestedList) {
1166
+ return { ...this.getProtoTypeFromGraphQL(getNamedType(listType), true), isRepeated: true };
1167
+ }
1168
+ // Nullable or nested lists need wrapper messages
1169
+ const baseType = getNamedType(listType);
1170
+ const nestingLevel = this.calculateNestingLevel(listType);
1171
+ // For nested lists, always use full nesting level to preserve inner list nullability
1172
+ // For single-level nullable lists, use nesting level 1
1173
+ const wrapperNestingLevel = isNestedList ? nestingLevel : 1;
1174
+ // Generate all required wrapper messages
1175
+ let wrapperName = '';
1176
+ for (let i = 1; i <= wrapperNestingLevel; i++) {
1177
+ wrapperName = this.createNestedListWrapper(i, baseType);
1178
+ }
1179
+ // For nested lists, never use repeated at field level to preserve nullability
1180
+ return { typeName: wrapperName, isRepeated: false };
1081
1181
  }
1082
1182
  /**
1083
- * Create a nested list wrapper message for the given base type
1183
+ * Unwraps a GraphQL type from a GraphQLNonNull type
1084
1184
  */
1085
- createNestedListWrapper(wrapperName, baseType) {
1086
- // Skip if already processed
1185
+ unwrapNonNullType(graphqlType) {
1186
+ return isNonNullType(graphqlType) ? graphqlType.ofType : graphqlType;
1187
+ }
1188
+ /**
1189
+ * Checks if a GraphQL list type contains nested lists
1190
+ * Type guard that narrows the input type when nested lists are detected
1191
+ */
1192
+ isNestedListType(listType) {
1193
+ return isListType(listType.ofType) || (isNonNullType(listType.ofType) && isListType(listType.ofType.ofType));
1194
+ }
1195
+ /**
1196
+ * Calculates the nesting level of a GraphQL list type
1197
+ */
1198
+ calculateNestingLevel(listType) {
1199
+ let level = 1;
1200
+ let currentType = listType.ofType;
1201
+ while (true) {
1202
+ if (isNonNullType(currentType)) {
1203
+ currentType = currentType.ofType;
1204
+ }
1205
+ else if (isListType(currentType)) {
1206
+ currentType = currentType.ofType;
1207
+ level++;
1208
+ }
1209
+ else {
1210
+ break;
1211
+ }
1212
+ }
1213
+ return level;
1214
+ }
1215
+ /**
1216
+ * Creates wrapper messages for nullable or nested GraphQL lists.
1217
+ *
1218
+ * Generates Protocol Buffer message definitions to handle list nullability and nesting.
1219
+ * The wrapper messages are stored and later included in the final proto output.
1220
+ *
1221
+ * For level 1: Creates simple wrapper like:
1222
+ * message ListOfString {
1223
+ * repeated string items = 1;
1224
+ * }
1225
+ *
1226
+ * For level > 1: Creates nested wrapper structures like:
1227
+ * message ListOfListOfString {
1228
+ * message List {
1229
+ * repeated ListOfString items = 1;
1230
+ * }
1231
+ * List list = 1;
1232
+ * }
1233
+ *
1234
+ * @param level - The nesting level (1 for simple wrapper, >1 for nested structures)
1235
+ * @param baseType - The GraphQL base type being wrapped (e.g., String, User, etc.)
1236
+ * @returns The generated wrapper message name (e.g., "ListOfString", "ListOfListOfUser")
1237
+ */
1238
+ createNestedListWrapper(level, baseType) {
1239
+ const wrapperName = `${'ListOf'.repeat(level)}${baseType.name}`;
1240
+ // Return existing wrapper if already created
1087
1241
  if (this.processedTypes.has(wrapperName) || this.nestedListWrappers.has(wrapperName)) {
1088
- return;
1242
+ return wrapperName;
1089
1243
  }
1090
- // Mark as processed to avoid recursion
1091
1244
  this.processedTypes.add(wrapperName);
1092
- // Check for field removals if lock data exists for this wrapper
1093
- const lockData = this.lockManager.getLockData();
1094
- if (lockData.messages[wrapperName]) {
1095
- const originalFieldNames = Object.keys(lockData.messages[wrapperName].fields);
1096
- const currentFieldNames = ['result'];
1097
- this.trackRemovedFields(wrapperName, originalFieldNames, currentFieldNames);
1098
- }
1099
- // Create a temporary array for the wrapper definition
1100
- const messageLines = [];
1101
- // Add a description comment for the wrapper message
1245
+ const messageLines = this.buildWrapperMessage(wrapperName, level, baseType);
1246
+ this.nestedListWrappers.set(wrapperName, messageLines.join('\n'));
1247
+ return wrapperName;
1248
+ }
1249
+ /**
1250
+ * Builds the message lines for a wrapper message
1251
+ */
1252
+ buildWrapperMessage(wrapperName, level, baseType) {
1253
+ const lines = [];
1254
+ // Add comment if enabled
1102
1255
  if (this.includeComments) {
1103
- const wrapperComment = `Wrapper message for a list of ${baseType.name}.`;
1104
- messageLines.push(...this.formatComment(wrapperComment, 0)); // Top-level comment, no indent
1256
+ lines.push(...this.formatComment(`Wrapper message for a list of ${baseType.name}.`, 0));
1257
+ }
1258
+ lines.push(`message ${wrapperName} {`);
1259
+ const formatIndent = (indent, content) => {
1260
+ return ' '.repeat(indent) + content;
1261
+ };
1262
+ if (level > 1) {
1263
+ // Nested structure for deep lists
1264
+ const innerWrapperName = `${'ListOf'.repeat(level - 1)}${baseType.name}`;
1265
+ lines.push(formatIndent(1, `message List {`), formatIndent(2, `repeated ${innerWrapperName} items = 1;`), formatIndent(1, `}`));
1266
+ // Wrapper types always use deterministic field numbers - 'list' field is always 1
1267
+ lines.push(formatIndent(1, `List list = 1;`));
1105
1268
  }
1106
- messageLines.push(`message ${wrapperName} {`);
1107
- // Add reserved field numbers if any exist
1108
- const messageLock = lockData.messages[wrapperName];
1109
- if ((messageLock === null || messageLock === void 0 ? void 0 : messageLock.reservedNumbers) && messageLock.reservedNumbers.length > 0) {
1110
- messageLines.push(` reserved ${this.formatReservedNumbers(messageLock.reservedNumbers)};`);
1269
+ else {
1270
+ // Simple repeated field for level 1 - 'items' field is always 1
1271
+ const protoType = this.getProtoTypeFromGraphQL(baseType, true);
1272
+ lines.push(formatIndent(1, `repeated ${protoType.typeName} items = 1;`));
1111
1273
  }
1112
- // Get the appropriate field number from the lock
1113
- const fieldNumber = this.getFieldNumber(wrapperName, 'result', 1);
1114
- // For the inner type, we need to get the proto type for the base type
1115
- const protoType = this.getProtoTypeFromGraphQL(baseType);
1116
- messageLines.push(` repeated ${protoType} result = ${fieldNumber};`);
1117
- messageLines.push('}');
1118
- messageLines.push('');
1119
- // Ensure the wrapper message is registered in the lock manager data
1120
- this.lockManager.reconcileMessageFieldOrder(wrapperName, ['result']);
1121
- // Store the wrapper message for later inclusion in the output
1122
- this.nestedListWrappers.set(wrapperName, messageLines.join('\n'));
1274
+ lines.push('}', '');
1275
+ return lines;
1123
1276
  }
1124
1277
  /**
1125
1278
  * Get indentation based on the current level