Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions protographic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Protographic bridges GraphQL and Protocol Buffers (protobuf) ecosystems through
- Robust handling of complex GraphQL features (unions, interfaces, directives)
- First-class support for Federation entity mapping
- Deterministic field ordering with proto.lock.json for backward compatibility
- Use of Protocol Buffer wrappers for nullable fields to distinguish between semantic nulls and zero values

## Installation

Expand Down
34 changes: 20 additions & 14 deletions protographic/SDL_PROTO_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ Rules should follow [Proto Best Practices](https://protobuf.dev/best-practices/d

### Scalar Types

| GraphQL Type | Protocol Buffer Type |
| ------------ | -------------------- |
| ID | string |
| String | string |
| Int | int32 |
| Float | double |
| Boolean | bool |
| GraphQL Type | Protocol Buffer Type (Non-Null) | Protocol Buffer Type (Nullable) |
| ------------ | ------------------------------- | ------------------------------- |
| ID | string | google.protobuf.StringValue |
| String | string | google.protobuf.StringValue |
| Int | int32 | google.protobuf.Int32Value |
| Float | double | google.protobuf.DoubleValue |
| Boolean | bool | google.protobuf.BoolValue |

### Complex Types

Expand Down Expand Up @@ -286,13 +286,15 @@ type User {
Generates:

```protobuf
import "google/protobuf/wrappers.proto";

message User {
string id = 1;
string name = 2;
string email = 3;
int32 age = 4;
string bio = 5;
bool is_active = 6;
google.protobuf.Int32Value age = 4;
google.protobuf.StringValue bio = 5;
google.protobuf.BoolValue is_active = 6;
}
```

Expand All @@ -312,11 +314,13 @@ type User {
Generates (with range notation for reserved fields):

```protobuf
import "google/protobuf/wrappers.proto";

message User {
reserved 3 to 5; // Efficiently reserves fields 3, 4, and 5
string id = 1;
string name = 2;
bool is_active = 6;
google.protobuf.BoolValue is_active = 6;
}
```

Expand All @@ -335,13 +339,15 @@ type User {
Generates:

```protobuf
import "google/protobuf/wrappers.proto";

message User {
reserved 3 to 4; // Fields 3 and 4 remain reserved
string id = 1;
string name = 2;
string bio = 5; // Restored field keeps its original number
bool is_active = 6;
string created_at = 7; // New field gets next available number
google.protobuf.StringValue bio = 5; // Restored field keeps its original number
google.protobuf.BoolValue is_active = 6;
google.protobuf.StringValue created_at = 7; // New field gets next available number
}
```

Expand Down
72 changes: 65 additions & 7 deletions protographic/src/sdl-to-proto-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ const SCALAR_TYPE_MAP: Record<string, string> = {
Boolean: 'bool', // Direct mapping
};

/**
* Maps GraphQL scalar types to Protocol Buffer wrapper types for nullable fields
*
* These wrapper types allow distinguishing between unset fields and zero values
* in Protocol Buffers, which is important for GraphQL nullable semantics.
*/
const SCALAR_WRAPPER_TYPE_MAP: Record<string, string> = {
ID: 'google.protobuf.StringValue',
String: 'google.protobuf.StringValue',
Int: 'google.protobuf.Int32Value',
Float: 'google.protobuf.DoubleValue',
Boolean: 'google.protobuf.BoolValue',
};

/**
* Generic structure for returning RPC and message definitions
*/
Expand Down Expand Up @@ -116,6 +130,9 @@ export class GraphQLToProtoTextVisitor {
/** Track generated nested list wrapper messages */
private nestedListWrappers = new Map<string, string>();

/** Track whether wrapper types are used (for conditional import) */
private usesWrapperTypes = false;

/**
* Map of message names to their field numbers for tracking deleted fields
* This maintains field numbers even when fields are removed from the schema
Expand Down Expand Up @@ -424,6 +441,25 @@ export class GraphQLToProtoTextVisitor {
// 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";', '');
}
}

// Store the generated lock data for retrieval
this.generatedLockData = this.lockManager.getLockData();

Expand Down Expand Up @@ -1322,21 +1358,34 @@ Example:
* Map GraphQL type to Protocol Buffer type
*
* Determines the appropriate Protocol Buffer type for a given GraphQL type,
* handling all GraphQL type wrappers (NonNull, List) correctly.
* including the use of wrapper types for nullable scalar fields to distinguish
* between unset fields and zero values.
*
* @param graphqlType - The GraphQL type to convert
* @param ignoreWrapperTypes - If true, do not use wrapper types for nullable scalar fields
* @returns The corresponding Protocol Buffer type name
*/
private getProtoTypeFromGraphQL(graphqlType: GraphQLType): string {
private getProtoTypeFromGraphQL(graphqlType: GraphQLType, ignoreWrapperTypes: boolean = false): string {

// For nullable scalar types, use wrapper types
if (isScalarType(graphqlType)) {
return SCALAR_TYPE_MAP[graphqlType.name] || 'string';
if(ignoreWrapperTypes){
return SCALAR_TYPE_MAP[graphqlType.name] || 'string';
}
this.usesWrapperTypes = true; // Track that we're using wrapper types
return SCALAR_WRAPPER_TYPE_MAP[graphqlType.name] || 'google.protobuf.StringValue';
}

if (isEnumType(graphqlType)) {
return graphqlType.name;
}

if (isNonNullType(graphqlType)) {
// For non-null scalar types, use the base type
if (isScalarType(graphqlType.ofType)) {
return SCALAR_TYPE_MAP[graphqlType.ofType.name] || 'string';
}

return this.getProtoTypeFromGraphQL(graphqlType.ofType);
}

Expand All @@ -1348,7 +1397,16 @@ Example:
if (isListType(innerType) || (isNonNullType(innerType) && isListType(innerType.ofType))) {
// Find the most inner type by unwrapping all lists and non-nulls
let currentType: GraphQLType = innerType;

// The inner most type should still keep null vs non-null attribute of the original type.
// This is important for the wrapper message to be generated correctly.
let innerMostType: GraphQLType = currentType;
while (isListType(currentType) || isNonNullType(currentType)) {

// Check if the inner type of this non-null type
if(isNonNullType(currentType) && !isListType(currentType.ofType)){
innerMostType = currentType
}
currentType = isListType(currentType) ? currentType.ofType : (currentType as any).ofType;
}

Expand All @@ -1358,13 +1416,13 @@ Example:

// Generate the wrapper message if not already created
if (!this.processedTypes.has(wrapperName) && !this.nestedListWrappers.has(wrapperName)) {
this.createNestedListWrapper(wrapperName, namedInnerType);
this.createNestedListWrapper(wrapperName, namedInnerType, innerMostType);
}

return wrapperName;
}

return this.getProtoTypeFromGraphQL(innerType);
return this.getProtoTypeFromGraphQL(innerType, true);
}

// Named types (object, interface, union, input)
Expand All @@ -1379,7 +1437,7 @@ Example:
/**
* Create a nested list wrapper message for the given base type
*/
private createNestedListWrapper(wrapperName: string, baseType: GraphQLNamedType): void {
private createNestedListWrapper(wrapperName: string, baseType: GraphQLNamedType, innerMostType: GraphQLType): void {
// Skip if already processed
if (this.processedTypes.has(wrapperName) || this.nestedListWrappers.has(wrapperName)) {
return;
Expand Down Expand Up @@ -1417,7 +1475,7 @@ Example:
const fieldNumber = this.getFieldNumber(wrapperName, 'result', 1);

// For the inner type, we need to get the proto type for the base type
const protoType = this.getProtoTypeFromGraphQL(baseType);
const protoType = this.getProtoTypeFromGraphQL(baseType, true);
messageLines.push(` repeated ${protoType} result = ${fieldNumber};`);

messageLines.push('}');
Expand Down
Loading