diff --git a/protographic/src/sdl-to-proto-visitor.ts b/protographic/src/sdl-to-proto-visitor.ts index e3ecf12b20..91a4507f58 100644 --- a/protographic/src/sdl-to-proto-visitor.ts +++ b/protographic/src/sdl-to-proto-visitor.ts @@ -106,12 +106,21 @@ export class GraphQLToProtoTextVisitor { /** The name of the Protocol Buffer service */ private readonly serviceName: string; + /** The package name for the proto file */ + private readonly packageName: string; + /** The lock manager for deterministic ordering */ private readonly lockManager: ProtoLockManager; /** Generated proto lock data */ private generatedLockData: ProtoLock | null = null; + /** List of import statements */ + private imports: string[] = []; + + /** List of option statements */ + private options: string[] = []; + /** Accumulates the Protocol Buffer definition text */ private protoText: string[] = []; @@ -156,6 +165,7 @@ export class GraphQLToProtoTextVisitor { this.schema = schema; this.serviceName = serviceName; + this.packageName = packageName; this.lockManager = new ProtoLockManager(lockData); this.includeComments = includeComments; @@ -164,17 +174,13 @@ export class GraphQLToProtoTextVisitor { this.initializeFieldNumbersMap(lockData); } - const protoOptions = []; - + // Initialize options if (goPackage && goPackage !== '') { // Generate default go_package if not provided const defaultGoPackage = `cosmo/pkg/proto/${packageName};${packageName.replace('.', '')}`; const goPackageOption = goPackage || defaultGoPackage; - protoOptions.push(`option go_package = "${goPackageOption}";\n`); + this.options.push(`option go_package = "${goPackageOption}";`); } - - // Initialize the Proto definition with the standard header - this.protoText = ['syntax = "proto3";', `package ${packageName};`, '', ...protoOptions]; } /** @@ -358,16 +364,66 @@ export class GraphQLToProtoTextVisitor { return Math.max(...usedNumbers) + 1; } + /** + * Add an import statement to the proto file + */ + private addImport(importPath: string): void { + if (!this.imports.includes(importPath)) { + this.imports.push(importPath); + } + } + + /** + * Add an option statement to the proto file + */ + private addOption(optionStatement: string): void { + if (!this.options.includes(optionStatement)) { + this.options.push(optionStatement); + } + } + + /** + * Build the proto file header with syntax, package, imports, and options + */ + private buildProtoHeader(): string[] { + const header: string[] = []; + + // Add syntax declaration + header.push('syntax = "proto3";'); + + // Add package declaration + header.push(`package ${this.packageName};`); + header.push(''); + + // Add options if any (options come before imports) + if (this.options.length > 0) { + // Sort options for consistent output + const sortedOptions = [...this.options].sort(); + for (const option of sortedOptions) { + header.push(option); + } + header.push(''); + } + + // Add imports if any + if (this.imports.length > 0) { + // Sort imports for consistent output + const sortedImports = [...this.imports].sort(); + for (const importPath of sortedImports) { + header.push(`import "${importPath}";`); + } + header.push(''); + } + + return header; + } + /** * Visit the GraphQL schema to generate Proto buffer definition * * @returns The complete Protocol Buffer definition as a string */ public visit(): string { - // Clear the protoText array to just contain the header - const headerText = this.protoText.slice(); - this.protoText = []; - // Collect RPC methods and message definitions from all sources const entityResult = this.collectEntityRpcMethods(); const queryResult = this.collectQueryRpcMethods(); @@ -386,17 +442,28 @@ export class GraphQLToProtoTextVisitor { // Add all types from the schema to the queue that weren't already queued this.queueAllSchemaTypes(); - // Start with the header - this.protoText = headerText; + // Process all complex types from the message queue to determine if wrapper types are needed + this.processMessageQueue(); + + // Add wrapper import if needed + if (this.usesWrapperTypes) { + this.addImport('google/protobuf/wrappers.proto'); + } + + // Build the complete proto file + const protoContent: string[] = []; + + // Add the header (syntax, package, imports, options) + protoContent.push(...this.buildProtoHeader()); // Add a service description comment if (this.includeComments) { const serviceComment = `Service definition for ${this.serviceName}`; - this.protoText.push(...this.formatComment(serviceComment, 0)); // Top-level comment, no indent + protoContent.push(...this.formatComment(serviceComment, 0)); // Top-level comment, no indent } - // First: Create service block containing only RPC methods - this.protoText.push(`service ${this.serviceName} {`); + // Add service block containing RPC methods + protoContent.push(`service ${this.serviceName} {`); this.indent++; // Sort method names deterministically by alphabetical order @@ -411,60 +478,40 @@ export class GraphQLToProtoTextVisitor { if (rpcMethodText.includes('\n')) { // For multi-line RPC method definitions (with comments), add each line separately const lines = rpcMethodText.split('\n'); - this.protoText.push(...lines); + protoContent.push(...lines); } else { // For simple one-line RPC method definitions (ensure 2-space indentation) - this.protoText.push(` ${rpcMethodText}`); + protoContent.push(` ${rpcMethodText}`); } } } // Close service definition this.indent--; - this.protoText.push('}'); - this.protoText.push(''); + protoContent.push('}'); + protoContent.push(''); // Add all wrapper messages first since they might be referenced by other messages if (this.nestedListWrappers.size > 0) { // Sort the wrappers by name for deterministic output const sortedWrapperNames = Array.from(this.nestedListWrappers.keys()).sort(); for (const wrapperName of sortedWrapperNames) { - this.protoText.push(this.nestedListWrappers.get(wrapperName)!); + protoContent.push(this.nestedListWrappers.get(wrapperName)!); } } - // Second: Add all message definitions + // Add all message definitions for (const messageDef of allMessageDefinitions) { - this.protoText.push(messageDef); + protoContent.push(messageDef); } - // Third: Process all complex types from the message queue in a single pass - this.processMessageQueue(); - - // Add wrapper import if needed, at the correct position - if (this.usesWrapperTypes) { - // Find the position after the package declaration - const packageIndex = this.protoText.findIndex((line) => line.startsWith('package ')); - if (packageIndex !== -1) { - // Insert after package line and any existing options, but before service - let insertIndex = packageIndex + 1; - - // Skip over any existing options and empty lines - while ( - insertIndex < this.protoText.length && - (this.protoText[insertIndex].startsWith('option ') || this.protoText[insertIndex].trim() === '') - ) { - insertIndex++; - } - - this.protoText.splice(insertIndex, 0, 'import "google/protobuf/wrappers.proto";', ''); - } - } + // Add all processed types from protoText (populated by processMessageQueue) + protoContent.push(...this.protoText); // Store the generated lock data for retrieval this.generatedLockData = this.lockManager.getLockData(); - return this.protoText.join('\n'); + return protoContent.join('\n'); } /** diff --git a/protographic/tests/sdl-to-proto/01-basic-types.test.ts b/protographic/tests/sdl-to-proto/01-basic-types.test.ts index f0a32b5900..c77a85a517 100644 --- a/protographic/tests/sdl-to-proto/01-basic-types.test.ts +++ b/protographic/tests/sdl-to-proto/01-basic-types.test.ts @@ -586,4 +586,158 @@ describe('SDL to Proto - Basic Types', () => { }" `); }); + + test('should use wrapper types in nested and recursive scenarios', () => { + const sdl = ` + type User { + id: ID! + name: String! + email: String + profile: UserProfile + friends: [User] + } + + type UserProfile { + bio: String + age: Int + isVerified: Boolean + socialLinks: SocialLinks + } + + type SocialLinks { + twitter: String + linkedin: String + website: String + } + + type TreeNode { + id: ID! + value: String + weight: Float + isLeaf: Boolean! + children: [TreeNode] + parent: TreeNode + } + + type Query { + user(id: ID!): User + tree(id: ID!): TreeNode + } + + input UserInput { + name: String! + email: String + profile: UserProfileInput + } + + input UserProfileInput { + bio: String + age: Int + isVerified: Boolean + } + + type Mutation { + createUser(input: UserInput!): User + updateTreeNode(id: ID!, value: String, weight: Float): TreeNode + } + `; + + const { proto: protoText } = compileGraphQLToProto(sdl); + + // Validate Proto definition + expectValidProto(protoText); + + // Full snapshot to ensure wrapper types are used correctly in nested and recursive scenarios + expect(protoText).toMatchInlineSnapshot(` + "syntax = "proto3"; + package service.v1; + + import "google/protobuf/wrappers.proto"; + + // Service definition for DefaultService + service DefaultService { + rpc MutationCreateUser(MutationCreateUserRequest) returns (MutationCreateUserResponse) {} + rpc MutationUpdateTreeNode(MutationUpdateTreeNodeRequest) returns (MutationUpdateTreeNodeResponse) {} + rpc QueryTree(QueryTreeRequest) returns (QueryTreeResponse) {} + rpc QueryUser(QueryUserRequest) returns (QueryUserResponse) {} + } + + // Request message for user operation. + message QueryUserRequest { + string id = 1; + } + // Response message for user operation. + message QueryUserResponse { + User user = 1; + } + // Request message for tree operation. + message QueryTreeRequest { + string id = 1; + } + // Response message for tree operation. + message QueryTreeResponse { + TreeNode tree = 1; + } + // Request message for createUser operation. + message MutationCreateUserRequest { + UserInput input = 1; + } + // Response message for createUser operation. + message MutationCreateUserResponse { + User create_user = 1; + } + // Request message for updateTreeNode operation. + message MutationUpdateTreeNodeRequest { + string id = 1; + google.protobuf.StringValue value = 2; + google.protobuf.DoubleValue weight = 3; + } + // Response message for updateTreeNode operation. + message MutationUpdateTreeNodeResponse { + TreeNode update_tree_node = 1; + } + + message User { + string id = 1; + string name = 2; + google.protobuf.StringValue email = 3; + UserProfile profile = 4; + repeated User friends = 5; + } + + message TreeNode { + string id = 1; + google.protobuf.StringValue value = 2; + google.protobuf.DoubleValue weight = 3; + bool is_leaf = 4; + repeated TreeNode children = 5; + TreeNode parent = 6; + } + + message UserInput { + string name = 1; + google.protobuf.StringValue email = 2; + UserProfileInput profile = 3; + } + + message UserProfile { + google.protobuf.StringValue bio = 1; + google.protobuf.Int32Value age = 2; + google.protobuf.BoolValue is_verified = 3; + SocialLinks social_links = 4; + } + + message SocialLinks { + google.protobuf.StringValue twitter = 1; + google.protobuf.StringValue linkedin = 2; + google.protobuf.StringValue website = 3; + } + + message UserProfileInput { + google.protobuf.StringValue bio = 1; + google.protobuf.Int32Value age = 2; + google.protobuf.BoolValue is_verified = 3; + }" + `); + }); });