Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 92 additions & 45 deletions protographic/src/sdl-to-proto-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -156,6 +165,7 @@ export class GraphQLToProtoTextVisitor {

this.schema = schema;
this.serviceName = serviceName;
this.packageName = packageName;
this.lockManager = new ProtoLockManager(lockData);
this.includeComments = includeComments;

Expand All @@ -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];
}

/**
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand All @@ -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');
}

/**
Expand Down
154 changes: 154 additions & 0 deletions protographic/tests/sdl-to-proto/01-basic-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}"
`);
});
});