From b47ee749277fc9c3707fb9c6330a012443789fcc Mon Sep 17 00:00:00 2001 From: endigma Date: Mon, 4 Aug 2025 16:41:22 +0100 Subject: [PATCH 1/6] feat: multiple keys and compound keys for entity rpc --- protographic/src/naming-conventions.ts | 22 +- protographic/src/sdl-to-mapping-visitor.ts | 47 +-- protographic/src/sdl-to-proto-visitor.ts | 98 +++-- .../sdl-to-mapping/03-federation.test.ts | 260 +++++++++++++ .../tests/sdl-to-proto/04-federation.test.ts | 348 +++++++++++++++++- 5 files changed, 692 insertions(+), 83 deletions(-) diff --git a/protographic/src/naming-conventions.ts b/protographic/src/naming-conventions.ts index e0dffcd6f4..a4ba0f640a 100644 --- a/protographic/src/naming-conventions.ts +++ b/protographic/src/naming-conventions.ts @@ -50,22 +50,16 @@ export function createResponseMessageName(methodName: string): string { /** * Creates an entity lookup method name for an entity type */ -export function createEntityLookupMethodName(typeName: string, keyField: string = 'id'): string { - return `Lookup${typeName}By${upperFirst(camelCase(keyField))}`; -} +export function createEntityLookupMethodName(typeName: string, keyString: string = 'id'): string { + const fields = keyString.split(' '); -/** - * Creates a request message name for an entity lookup - */ -export function createEntityLookupRequestName(typeName: string, keyField: string = 'id'): string { - return `Lookup${typeName}By${upperFirst(camelCase(keyField))}Request`; -} + if (fields.length === 1) { + return `Lookup${typeName}By${upperFirst(camelCase(fields[0]))}`; + } -/** - * Creates a response message name for an entity lookup - */ -export function createEntityLookupResponseName(typeName: string, keyField: string = 'id'): string { - return `Lookup${typeName}By${upperFirst(camelCase(keyField))}Response`; + // For compound keys: LookupProductByIdAndUpc + const keyPart = fields.map((field) => upperFirst(camelCase(field))).join('And'); + return `Lookup${typeName}By${keyPart}`; } /** diff --git a/protographic/src/sdl-to-mapping-visitor.ts b/protographic/src/sdl-to-mapping-visitor.ts index b0580e8a83..3f2cb94e67 100644 --- a/protographic/src/sdl-to-mapping-visitor.ts +++ b/protographic/src/sdl-to-mapping-visitor.ts @@ -13,8 +13,6 @@ import { } from 'graphql'; import { createEntityLookupMethodName, - createEntityLookupRequestName, - createEntityLookupResponseName, createOperationMethodName, createRequestMessageName, createResponseMessageName, @@ -116,26 +114,29 @@ export class GraphQLToProtoVisitor { // Check if this is an entity type (has @key directive) if (isObjectType(type)) { - const keyDirective = this.getKeyDirective(type); - if (!keyDirective) continue; - - const keyFields = this.getKeyFieldsFromDirective(keyDirective); - if (keyFields.length > 0) { - // Create entity mapping using the first key field - this.createEntityMapping(typeName, keyFields[0]); + const keyDirectives = this.getKeyDirectives(type); + if (keyDirectives.length === 0) continue; + + // Process each @key directive separately + for (const keyDirective of keyDirectives) { + const key = this.getKeyFromDirective(keyDirective); + if (key) { + // Create entity mapping for each key combination + this.createEntityMapping(typeName, key); + } } } } } /** - * Extract the key directive from a GraphQL object type + * Extract all key directives from a GraphQL object type * - * @param type - The GraphQL object type to check for key directive - * @returns The key directive if found, undefined otherwise + * @param type - The GraphQL object type to check for key directives + * @returns Array of all key directives found */ - private getKeyDirective(type: GraphQLObjectType): DirectiveNode | undefined { - return type.astNode?.directives?.find((d) => d.name.value === 'key'); + private getKeyDirectives(type: GraphQLObjectType): DirectiveNode[] { + return type.astNode?.directives?.filter((d) => d.name.value === 'key') || []; } /** @@ -148,13 +149,15 @@ export class GraphQLToProtoVisitor { * @param keyField - The field that serves as the entity's key */ private createEntityMapping(typeName: string, keyField: string): void { + const rpc = createEntityLookupMethodName(typeName, keyField); + const entityMapping = new EntityMapping({ typeName, kind: 'entity', key: keyField, - rpc: createEntityLookupMethodName(typeName, keyField), - request: createEntityLookupRequestName(typeName, keyField), - response: createEntityLookupResponseName(typeName, keyField), + rpc: rpc, + request: createRequestMessageName(rpc), + response: createResponseMessageName(rpc), }); this.mapping.entityMappings.push(entityMapping); @@ -164,18 +167,18 @@ export class GraphQLToProtoVisitor { * Extract key fields from a @key directive * * The @key directive specifies which fields form the entity's primary key - * in Federation. This method extracts those field names. + * in Federation. This method extracts the key string as-is. * * @param directive - The @key directive from the GraphQL AST - * @returns Array of field names that form the key + * @returns The key string (e.g. "id" or "id upc") */ - private getKeyFieldsFromDirective(directive: DirectiveNode): string[] { + private getKeyFromDirective(directive: DirectiveNode): string | null { // Extract fields argument from the key directive const fieldsArg = directive.arguments?.find((arg) => arg.name.value === 'fields'); if (fieldsArg && fieldsArg.value.kind === Kind.STRING) { - return fieldsArg.value.value.split(' '); + return fieldsArg.value.value; } - return []; + return null; } /** diff --git a/protographic/src/sdl-to-proto-visitor.ts b/protographic/src/sdl-to-proto-visitor.ts index b02607faaa..5e8a46a583 100644 --- a/protographic/src/sdl-to-proto-visitor.ts +++ b/protographic/src/sdl-to-proto-visitor.ts @@ -25,8 +25,6 @@ import { } from 'graphql'; import { createEntityLookupMethodName, - createEntityLookupRequestName, - createEntityLookupResponseName, createEnumUnspecifiedValue, createOperationMethodName, createRequestMessageName, @@ -551,32 +549,36 @@ export class GraphQLToProtoTextVisitor { // Check if this is an entity type (has @key directive) if (isObjectType(type)) { - const astNode = type.astNode; - const keyDirective = astNode?.directives?.find((d) => d.name.value === 'key'); + const keyDirectives = this.getKeyDirectives(type); - if (keyDirective) { - // Queue this type for message generation + if (keyDirectives.length > 0) { + // Queue this type for message generation (only once) this.queueTypeForProcessing(type); - const keyFields = this.getKeyFieldsFromDirective(keyDirective); - if (keyFields.length > 0) { - const keyField = keyFields[0]; - const methodName = createEntityLookupMethodName(typeName, keyField); - const requestName = createEntityLookupRequestName(typeName, keyField); - const responseName = createEntityLookupResponseName(typeName, keyField); - - // Add method name and RPC method with description from the entity type - result.methodNames.push(methodName); - const description = `Lookup ${typeName} entity by ${keyField}${ - type.description ? ': ' + type.description : '' - }`; - result.rpcMethods.push(this.createRpcMethod(methodName, requestName, responseName, description)); - - // Create request and response messages - result.messageDefinitions.push( - ...this.createKeyRequestMessage(typeName, requestName, keyFields[0], responseName), - ); - result.messageDefinitions.push(...this.createKeyResponseMessage(typeName, responseName, requestName)); + // Process each @key directive separately + for (const keyDirective of keyDirectives) { + const keyString = this.getKeyFromDirective(keyDirective); + if (keyString) { + const methodName = createEntityLookupMethodName(typeName, keyString); + + const requestName = createRequestMessageName(methodName); + const responseName = createResponseMessageName(methodName); + + // Add method name and RPC method with description from the entity type + result.methodNames.push(methodName); + const keyFields = keyString.split(' '); + const keyDescription = keyFields.length === 1 ? keyFields[0] : keyFields.join(' and '); + const description = `Lookup ${typeName} entity by ${keyDescription}${ + type.description ? ': ' + type.description : '' + }`; + result.rpcMethods.push(this.createRpcMethod(methodName, requestName, responseName, description)); + + // Create request and response messages for this key combination + result.messageDefinitions.push( + ...this.createKeyRequestMessage(typeName, requestName, keyString, responseName), + ); + result.messageDefinitions.push(...this.createKeyResponseMessage(typeName, responseName, requestName)); + } } } } @@ -697,7 +699,7 @@ export class GraphQLToProtoTextVisitor { private createKeyRequestMessage( typeName: string, requestName: string, - keyField: string, + keyString: string, responseName: string, ): string[] { const messageLines: string[] = []; @@ -717,28 +719,36 @@ export class GraphQLToProtoTextVisitor { messageLines.push(` reserved ${this.formatReservedNumbers(keyMessageLock.reservedNumbers)};`); } + const keyFields = keyString.split(' '); + // Check for field removals in the key message if (lockData.messages[keyMessageName]) { const originalKeyFieldNames = Object.keys(lockData.messages[keyMessageName].fields); - const currentKeyFieldNames = [graphqlFieldToProtoField(keyField)]; + const currentKeyFieldNames = keyFields.map((field) => graphqlFieldToProtoField(field)); this.trackRemovedFields(keyMessageName, originalKeyFieldNames, currentKeyFieldNames); } - const protoKeyField = graphqlFieldToProtoField(keyField); + // Add all key fields to the key message + const protoKeyFields: string[] = []; + keyFields.forEach((keyField, index) => { + const protoKeyField = graphqlFieldToProtoField(keyField); + protoKeyFields.push(protoKeyField); - // Get the appropriate field number for the key field - const keyFieldNumber = this.getFieldNumber(keyMessageName, protoKeyField, 1); + // Get the appropriate field number for this key field + const keyFieldNumber = this.getFieldNumber(keyMessageName, protoKeyField, index + 1); + + if (this.includeComments) { + const keyFieldComment = `Key field for ${typeName} entity lookup.`; + messageLines.push(...this.formatComment(keyFieldComment, 1)); // Field comment, indent 1 level + } + messageLines.push(` string ${protoKeyField} = ${keyFieldNumber};`); + }); - if (this.includeComments) { - const keyFieldComment = `Key field for ${typeName} entity lookup.`; - messageLines.push(...this.formatComment(keyFieldComment, 1)); // Field comment, indent 1 level - } - messageLines.push(` string ${protoKeyField} = ${keyFieldNumber};`); messageLines.push('}'); messageLines.push(''); // Ensure the key message is registered in the lock manager data - this.lockManager.reconcileMessageFieldOrder(keyMessageName, [protoKeyField]); + this.lockManager.reconcileMessageFieldOrder(keyMessageName, protoKeyFields); // Now create the main request message with a repeated key field // Check for field removals in the request message @@ -972,6 +982,16 @@ Example: return messageLines; } + /** + * Extract all key directives from a GraphQL object type + * + * @param type - The GraphQL object type to check for key directives + * @returns Array of all key directives found + */ + private getKeyDirectives(type: GraphQLObjectType): DirectiveNode[] { + return type.astNode?.directives?.filter((d) => d.name.value === 'key') || []; + } + /** * Extract key fields from a directive * @@ -981,13 +1001,13 @@ Example: * @param directive - The @key directive from the GraphQL AST * @returns Array of field names that form the key */ - private getKeyFieldsFromDirective(directive: DirectiveNode): string[] { + private getKeyFromDirective(directive: DirectiveNode): string | null { const fieldsArg = directive.arguments?.find((arg: ArgumentNode) => arg.name.value === 'fields'); if (fieldsArg && fieldsArg.value.kind === 'StringValue') { const stringValue = fieldsArg.value as StringValueNode; - return stringValue.value.split(' '); + return stringValue.value; } - return []; + return null; } /** diff --git a/protographic/tests/sdl-to-mapping/03-federation.test.ts b/protographic/tests/sdl-to-mapping/03-federation.test.ts index d75e844ba2..534c1ba46c 100644 --- a/protographic/tests/sdl-to-mapping/03-federation.test.ts +++ b/protographic/tests/sdl-to-mapping/03-federation.test.ts @@ -421,6 +421,14 @@ describe('GraphQL Federation to Proto Mapping', () => { "rpc": "LookupProductById", "typeName": "Product", }, + { + "key": "upc", + "kind": "entity", + "request": "LookupProductByUpcRequest", + "response": "LookupProductByUpcResponse", + "rpc": "LookupProductByUpc", + "typeName": "Product", + }, ], "operationMappings": [ { @@ -550,4 +558,256 @@ describe('GraphQL Federation to Proto Mapping', () => { } `); }); + + it('maps entity with multiple @key directives to separate entity mappings', () => { + const sdl = ` + directive @key(fields: String!) on OBJECT + + type Product @key(fields: "id") @key(fields: "upc") { + id: ID! + upc: String! + name: String! + price: Float! + } + + type Query { + products: [Product!]! + } + `; + + const mapping = compileGraphQLToMapping(sdl, 'ProductService'); + + expect(mapping.toJson()).toMatchInlineSnapshot(` + { + "entityMappings": [ + { + "key": "id", + "kind": "entity", + "request": "LookupProductByIdRequest", + "response": "LookupProductByIdResponse", + "rpc": "LookupProductById", + "typeName": "Product", + }, + { + "key": "upc", + "kind": "entity", + "request": "LookupProductByUpcRequest", + "response": "LookupProductByUpcResponse", + "rpc": "LookupProductByUpc", + "typeName": "Product", + }, + ], + "operationMappings": [ + { + "mapped": "QueryProducts", + "original": "products", + "request": "QueryProductsRequest", + "response": "QueryProductsResponse", + "type": "OPERATION_TYPE_QUERY", + }, + ], + "service": "ProductService", + "typeFieldMappings": [ + { + "fieldMappings": [ + { + "mapped": "products", + "original": "products", + }, + ], + "type": "Query", + }, + { + "fieldMappings": [ + { + "mapped": "id", + "original": "id", + }, + { + "mapped": "upc", + "original": "upc", + }, + { + "mapped": "name", + "original": "name", + }, + { + "mapped": "price", + "original": "price", + }, + ], + "type": "Product", + }, + ], + "version": 1, + } + `); + }); + + it('maps entity with compound key fields to single entity mapping', () => { + const sdl = ` + directive @key(fields: String!) on OBJECT + + type OrderItem @key(fields: "orderId itemId") { + orderId: ID! + itemId: ID! + quantity: Int! + price: Float! + } + + type Query { + orderItems: [OrderItem!]! + } + `; + + const mapping = compileGraphQLToMapping(sdl, 'OrderService'); + + expect(mapping.toJson()).toMatchInlineSnapshot(` + { + "entityMappings": [ + { + "key": "orderId itemId", + "kind": "entity", + "request": "LookupOrderItemByOrderIdAndItemIdRequest", + "response": "LookupOrderItemByOrderIdAndItemIdResponse", + "rpc": "LookupOrderItemByOrderIdAndItemId", + "typeName": "OrderItem", + }, + ], + "operationMappings": [ + { + "mapped": "QueryOrderItems", + "original": "orderItems", + "request": "QueryOrderItemsRequest", + "response": "QueryOrderItemsResponse", + "type": "OPERATION_TYPE_QUERY", + }, + ], + "service": "OrderService", + "typeFieldMappings": [ + { + "fieldMappings": [ + { + "mapped": "order_items", + "original": "orderItems", + }, + ], + "type": "Query", + }, + { + "fieldMappings": [ + { + "mapped": "order_id", + "original": "orderId", + }, + { + "mapped": "item_id", + "original": "itemId", + }, + { + "mapped": "quantity", + "original": "quantity", + }, + { + "mapped": "price", + "original": "price", + }, + ], + "type": "OrderItem", + }, + ], + "version": 1, + } + `); + }); + + it('maps entity with mixed multiple and compound keys', () => { + const sdl = ` + directive @key(fields: String!) on OBJECT + + type Product @key(fields: "id") @key(fields: "manufacturerId productCode") { + id: ID! + manufacturerId: ID! + productCode: String! + name: String! + price: Float! + } + + type Query { + products: [Product!]! + } + `; + + const mapping = compileGraphQLToMapping(sdl, 'ProductService'); + + expect(mapping.toJson()).toMatchInlineSnapshot(` + { + "entityMappings": [ + { + "key": "id", + "kind": "entity", + "request": "LookupProductByIdRequest", + "response": "LookupProductByIdResponse", + "rpc": "LookupProductById", + "typeName": "Product", + }, + { + "key": "manufacturerId productCode", + "kind": "entity", + "request": "LookupProductByManufacturerIdAndProductCodeRequest", + "response": "LookupProductByManufacturerIdAndProductCodeResponse", + "rpc": "LookupProductByManufacturerIdAndProductCode", + "typeName": "Product", + }, + ], + "operationMappings": [ + { + "mapped": "QueryProducts", + "original": "products", + "request": "QueryProductsRequest", + "response": "QueryProductsResponse", + "type": "OPERATION_TYPE_QUERY", + }, + ], + "service": "ProductService", + "typeFieldMappings": [ + { + "fieldMappings": [ + { + "mapped": "products", + "original": "products", + }, + ], + "type": "Query", + }, + { + "fieldMappings": [ + { + "mapped": "id", + "original": "id", + }, + { + "mapped": "manufacturer_id", + "original": "manufacturerId", + }, + { + "mapped": "product_code", + "original": "productCode", + }, + { + "mapped": "name", + "original": "name", + }, + { + "mapped": "price", + "original": "price", + }, + ], + "type": "Product", + }, + ], + "version": 1, + } + `); + }); }); diff --git a/protographic/tests/sdl-to-proto/04-federation.test.ts b/protographic/tests/sdl-to-proto/04-federation.test.ts index 4f2170f719..891745bf87 100644 --- a/protographic/tests/sdl-to-proto/04-federation.test.ts +++ b/protographic/tests/sdl-to-proto/04-federation.test.ts @@ -166,30 +166,32 @@ describe('SDL to Proto - Federation and Special Types', () => { // Service definition for DefaultService service DefaultService { - // Lookup OrderItem entity by orderId - rpc LookupOrderItemByOrderId(LookupOrderItemByOrderIdRequest) returns (LookupOrderItemByOrderIdResponse) {} + // Lookup OrderItem entity by orderId and itemId + rpc LookupOrderItemByOrderIdAndItemId(LookupOrderItemByOrderIdAndItemIdRequest) returns (LookupOrderItemByOrderIdAndItemIdResponse) {} rpc QueryOrderItem(QueryOrderItemRequest) returns (QueryOrderItemResponse) {} } // Key message for OrderItem entity lookup - message LookupOrderItemByOrderIdRequestKey { + message LookupOrderItemByOrderIdAndItemIdRequestKey { // Key field for OrderItem entity lookup. string order_id = 1; + // Key field for OrderItem entity lookup. + string item_id = 2; } // Request message for OrderItem entity lookup. - message LookupOrderItemByOrderIdRequest { + message LookupOrderItemByOrderIdAndItemIdRequest { /* * List of keys to look up OrderItem entities. - * Order matters - each key maps to one entity in LookupOrderItemByOrderIdResponse. + * Order matters - each key maps to one entity in LookupOrderItemByOrderIdAndItemIdResponse. */ - repeated LookupOrderItemByOrderIdRequestKey keys = 1; + repeated LookupOrderItemByOrderIdAndItemIdRequestKey keys = 1; } // Response message for OrderItem entity lookup. - message LookupOrderItemByOrderIdResponse { + message LookupOrderItemByOrderIdAndItemIdResponse { /* - * List of OrderItem entities in the same order as the keys in LookupOrderItemByOrderIdRequest. + * List of OrderItem entities in the same order as the keys in LookupOrderItemByOrderIdAndItemIdRequest. * Always return the same number of entities as keys. Use null for entities that cannot be found. * * Example: @@ -433,4 +435,334 @@ describe('SDL to Proto - Federation and Special Types', () => { }" `); }); + + test('should handle entity types with multiple @key directives', () => { + const sdl = ` + directive @key(fields: String!) on OBJECT | INTERFACE + + type Product @key(fields: "id") @key(fields: "upc") { + id: ID! + upc: String! + name: String! + price: Float! + } + + type Query { + products: [Product!]! + } + `; + + const { proto: protoText } = compileGraphQLToProto(sdl); + + // Validate Proto definition + expectValidProto(protoText); + + // Check that both lookup operations are present + expect(protoText).toMatchInlineSnapshot(` + "syntax = "proto3"; + package service.v1; + + // Service definition for DefaultService + service DefaultService { + // Lookup Product entity by id + rpc LookupProductById(LookupProductByIdRequest) returns (LookupProductByIdResponse) {} + // Lookup Product entity by upc + rpc LookupProductByUpc(LookupProductByUpcRequest) returns (LookupProductByUpcResponse) {} + rpc QueryProducts(QueryProductsRequest) returns (QueryProductsResponse) {} + } + + // Key message for Product entity lookup + message LookupProductByIdRequestKey { + // Key field for Product entity lookup. + string id = 1; + } + + // Request message for Product entity lookup. + message LookupProductByIdRequest { + /* + * List of keys to look up Product entities. + * Order matters - each key maps to one entity in LookupProductByIdResponse. + */ + repeated LookupProductByIdRequestKey keys = 1; + } + + // Response message for Product entity lookup. + message LookupProductByIdResponse { + /* + * List of Product entities in the same order as the keys in LookupProductByIdRequest. + * Always return the same number of entities as keys. Use null for entities that cannot be found. + * + * Example: + * LookupUserByIdRequest: + * keys: + * - id: 1 + * - id: 2 + * LookupUserByIdResponse: + * result: + * - id: 1 # User with id 1 found + * - null # User with id 2 not found + */ + repeated Product result = 1; + } + + // Key message for Product entity lookup + message LookupProductByUpcRequestKey { + // Key field for Product entity lookup. + string upc = 1; + } + + // Request message for Product entity lookup. + message LookupProductByUpcRequest { + /* + * List of keys to look up Product entities. + * Order matters - each key maps to one entity in LookupProductByUpcResponse. + */ + repeated LookupProductByUpcRequestKey keys = 1; + } + + // Response message for Product entity lookup. + message LookupProductByUpcResponse { + /* + * List of Product entities in the same order as the keys in LookupProductByUpcRequest. + * Always return the same number of entities as keys. Use null for entities that cannot be found. + * + * Example: + * LookupUserByIdRequest: + * keys: + * - id: 1 + * - id: 2 + * LookupUserByIdResponse: + * result: + * - id: 1 # User with id 1 found + * - null # User with id 2 not found + */ + repeated Product result = 1; + } + + // Request message for products operation. + message QueryProductsRequest { + } + // Response message for products operation. + message QueryProductsResponse { + repeated Product products = 1; + } + + message Product { + string id = 1; + string upc = 2; + string name = 3; + double price = 4; + }" + `); + }); + + test('should handle entity types with proper compound key fields', () => { + const sdl = ` + directive @key(fields: String!) on OBJECT | INTERFACE + + type OrderItem @key(fields: "orderId itemId") { + orderId: ID! + itemId: ID! + quantity: Int! + price: Float! + } + + type Query { + orderItems: [OrderItem!]! + } + `; + + const { proto: protoText } = compileGraphQLToProto(sdl); + + // Validate Proto definition + expectValidProto(protoText); + + // Check that compound key lookup with both fields is present + expect(protoText).toMatchInlineSnapshot(` + "syntax = "proto3"; + package service.v1; + + // Service definition for DefaultService + service DefaultService { + // Lookup OrderItem entity by orderId and itemId + rpc LookupOrderItemByOrderIdAndItemId(LookupOrderItemByOrderIdAndItemIdRequest) returns (LookupOrderItemByOrderIdAndItemIdResponse) {} + rpc QueryOrderItems(QueryOrderItemsRequest) returns (QueryOrderItemsResponse) {} + } + + // Key message for OrderItem entity lookup + message LookupOrderItemByOrderIdAndItemIdRequestKey { + // Key field for OrderItem entity lookup. + string order_id = 1; + // Key field for OrderItem entity lookup. + string item_id = 2; + } + + // Request message for OrderItem entity lookup. + message LookupOrderItemByOrderIdAndItemIdRequest { + /* + * List of keys to look up OrderItem entities. + * Order matters - each key maps to one entity in LookupOrderItemByOrderIdAndItemIdResponse. + */ + repeated LookupOrderItemByOrderIdAndItemIdRequestKey keys = 1; + } + + // Response message for OrderItem entity lookup. + message LookupOrderItemByOrderIdAndItemIdResponse { + /* + * List of OrderItem entities in the same order as the keys in LookupOrderItemByOrderIdAndItemIdRequest. + * Always return the same number of entities as keys. Use null for entities that cannot be found. + * + * Example: + * LookupUserByIdRequest: + * keys: + * - id: 1 + * - id: 2 + * LookupUserByIdResponse: + * result: + * - id: 1 # User with id 1 found + * - null # User with id 2 not found + */ + repeated OrderItem result = 1; + } + + // Request message for orderItems operation. + message QueryOrderItemsRequest { + } + // Response message for orderItems operation. + message QueryOrderItemsResponse { + repeated OrderItem order_items = 1; + } + + message OrderItem { + string order_id = 1; + string item_id = 2; + int32 quantity = 3; + double price = 4; + }" + `); + }); + + test('should handle entity types with mixed multiple and compound keys', () => { + const sdl = ` + directive @key(fields: String!) on OBJECT | INTERFACE + + type Product @key(fields: "id") @key(fields: "manufacturerId productCode") { + id: ID! + manufacturerId: ID! + productCode: String! + name: String! + price: Float! + } + + type Query { + products: [Product!]! + } + `; + + const { proto: protoText } = compileGraphQLToProto(sdl); + + // Validate Proto definition + expectValidProto(protoText); + + // Check that both single and compound key lookups are present + expect(protoText).toMatchInlineSnapshot(` + "syntax = "proto3"; + package service.v1; + + // Service definition for DefaultService + service DefaultService { + // Lookup Product entity by id + rpc LookupProductById(LookupProductByIdRequest) returns (LookupProductByIdResponse) {} + // Lookup Product entity by manufacturerId and productCode + rpc LookupProductByManufacturerIdAndProductCode(LookupProductByManufacturerIdAndProductCodeRequest) returns (LookupProductByManufacturerIdAndProductCodeResponse) {} + rpc QueryProducts(QueryProductsRequest) returns (QueryProductsResponse) {} + } + + // Key message for Product entity lookup + message LookupProductByIdRequestKey { + // Key field for Product entity lookup. + string id = 1; + } + + // Request message for Product entity lookup. + message LookupProductByIdRequest { + /* + * List of keys to look up Product entities. + * Order matters - each key maps to one entity in LookupProductByIdResponse. + */ + repeated LookupProductByIdRequestKey keys = 1; + } + + // Response message for Product entity lookup. + message LookupProductByIdResponse { + /* + * List of Product entities in the same order as the keys in LookupProductByIdRequest. + * Always return the same number of entities as keys. Use null for entities that cannot be found. + * + * Example: + * LookupUserByIdRequest: + * keys: + * - id: 1 + * - id: 2 + * LookupUserByIdResponse: + * result: + * - id: 1 # User with id 1 found + * - null # User with id 2 not found + */ + repeated Product result = 1; + } + + // Key message for Product entity lookup + message LookupProductByManufacturerIdAndProductCodeRequestKey { + // Key field for Product entity lookup. + string manufacturer_id = 1; + // Key field for Product entity lookup. + string product_code = 2; + } + + // Request message for Product entity lookup. + message LookupProductByManufacturerIdAndProductCodeRequest { + /* + * List of keys to look up Product entities. + * Order matters - each key maps to one entity in LookupProductByManufacturerIdAndProductCodeResponse. + */ + repeated LookupProductByManufacturerIdAndProductCodeRequestKey keys = 1; + } + + // Response message for Product entity lookup. + message LookupProductByManufacturerIdAndProductCodeResponse { + /* + * List of Product entities in the same order as the keys in LookupProductByManufacturerIdAndProductCodeRequest. + * Always return the same number of entities as keys. Use null for entities that cannot be found. + * + * Example: + * LookupUserByIdRequest: + * keys: + * - id: 1 + * - id: 2 + * LookupUserByIdResponse: + * result: + * - id: 1 # User with id 1 found + * - null # User with id 2 not found + */ + repeated Product result = 1; + } + + // Request message for products operation. + message QueryProductsRequest { + } + // Response message for products operation. + message QueryProductsResponse { + repeated Product products = 1; + } + + message Product { + string id = 1; + string manufacturer_id = 2; + string product_code = 3; + string name = 4; + double price = 5; + }" + `); + }); }); From 5eacde60b7bc3f51c303b9a72b558631c2a69bd9 Mon Sep 17 00:00:00 2001 From: endigma Date: Tue, 5 Aug 2025 09:45:07 +0100 Subject: [PATCH 2/6] Simplify compound key lookup method generation --- protographic/src/naming-conventions.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/protographic/src/naming-conventions.ts b/protographic/src/naming-conventions.ts index a4ba0f640a..2d1dce187a 100644 --- a/protographic/src/naming-conventions.ts +++ b/protographic/src/naming-conventions.ts @@ -52,12 +52,6 @@ export function createResponseMessageName(methodName: string): string { */ export function createEntityLookupMethodName(typeName: string, keyString: string = 'id'): string { const fields = keyString.split(' '); - - if (fields.length === 1) { - return `Lookup${typeName}By${upperFirst(camelCase(fields[0]))}`; - } - - // For compound keys: LookupProductByIdAndUpc const keyPart = fields.map((field) => upperFirst(camelCase(field))).join('And'); return `Lookup${typeName}By${keyPart}`; } From aa0fc103ea678339f089474ddee376a5fe0fd83a Mon Sep 17 00:00:00 2001 From: endigma Date: Tue, 5 Aug 2025 14:06:26 +0100 Subject: [PATCH 3/6] Handle malformed key fields with trim in naming conventions --- protographic/src/naming-conventions.ts | 2 +- .../sdl-to-mapping/03-federation.test.ts | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/protographic/src/naming-conventions.ts b/protographic/src/naming-conventions.ts index 2d1dce187a..c86f87e263 100644 --- a/protographic/src/naming-conventions.ts +++ b/protographic/src/naming-conventions.ts @@ -51,7 +51,7 @@ export function createResponseMessageName(methodName: string): string { * Creates an entity lookup method name for an entity type */ export function createEntityLookupMethodName(typeName: string, keyString: string = 'id'): string { - const fields = keyString.split(' '); + const fields = keyString.trim().split(' '); const keyPart = fields.map((field) => upperFirst(camelCase(field))).join('And'); return `Lookup${typeName}By${keyPart}`; } diff --git a/protographic/tests/sdl-to-mapping/03-federation.test.ts b/protographic/tests/sdl-to-mapping/03-federation.test.ts index 534c1ba46c..b5b51e46de 100644 --- a/protographic/tests/sdl-to-mapping/03-federation.test.ts +++ b/protographic/tests/sdl-to-mapping/03-federation.test.ts @@ -644,6 +644,84 @@ describe('GraphQL Federation to Proto Mapping', () => { `); }); + it('correctly handles malformed key fields', () => { + // fields has a space after id + const sdl = ` + directive @key(fields: String!) on OBJECT + + type Product @key(fields: "id ") { + id: ID! + upc: String! + name: String! + price: Float! + } + + type Query { + products: [Product!]! + } + `; + + const mapping = compileGraphQLToMapping(sdl, 'ProductService'); + + expect(mapping.toJson()).toMatchInlineSnapshot(` + { + "entityMappings": [ + { + "key": "id ", + "kind": "entity", + "request": "LookupProductByIdRequest", + "response": "LookupProductByIdResponse", + "rpc": "LookupProductById", + "typeName": "Product", + }, + ], + "operationMappings": [ + { + "mapped": "QueryProducts", + "original": "products", + "request": "QueryProductsRequest", + "response": "QueryProductsResponse", + "type": "OPERATION_TYPE_QUERY", + }, + ], + "service": "ProductService", + "typeFieldMappings": [ + { + "fieldMappings": [ + { + "mapped": "products", + "original": "products", + }, + ], + "type": "Query", + }, + { + "fieldMappings": [ + { + "mapped": "id", + "original": "id", + }, + { + "mapped": "upc", + "original": "upc", + }, + { + "mapped": "name", + "original": "name", + }, + { + "mapped": "price", + "original": "price", + }, + ], + "type": "Product", + }, + ], + "version": 1, + } + `); + }); + it('maps entity with compound key fields to single entity mapping', () => { const sdl = ` directive @key(fields: String!) on OBJECT From 5ce0f33a9f2247359d58ec1055e4468c35fa8321 Mon Sep 17 00:00:00 2001 From: endigma Date: Tue, 5 Aug 2025 14:34:07 +0100 Subject: [PATCH 4/6] Improve entity lookup method name generation --- protographic/src/naming-conventions.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/protographic/src/naming-conventions.ts b/protographic/src/naming-conventions.ts index c86f87e263..aad58da802 100644 --- a/protographic/src/naming-conventions.ts +++ b/protographic/src/naming-conventions.ts @@ -51,8 +51,13 @@ export function createResponseMessageName(methodName: string): string { * Creates an entity lookup method name for an entity type */ export function createEntityLookupMethodName(typeName: string, keyString: string = 'id'): string { - const fields = keyString.trim().split(' '); - const keyPart = fields.map((field) => upperFirst(camelCase(field))).join('And'); + const keyPart = keyString + .trim() + .replace(/,|\s\s/g, ' ') + .split(' ') + .map((field) => upperFirst(camelCase(field))) + .join('And'); + return `Lookup${typeName}By${keyPart}`; } From 2bd165c811bb3d85ca903fd5016c3e2e8af9be6b Mon Sep 17 00:00:00 2001 From: endigma Date: Wed, 6 Aug 2025 10:09:38 +0100 Subject: [PATCH 5/6] Normalize key strings before using for proto generation --- protographic/src/naming-conventions.ts | 4 +- protographic/src/sdl-to-proto-visitor.ts | 57 ++++++++++++------- .../sdl-to-mapping/03-federation.test.ts | 16 ++++-- .../tests/sdl-to-proto/04-federation.test.ts | 42 +++++++------- 4 files changed, 71 insertions(+), 48 deletions(-) diff --git a/protographic/src/naming-conventions.ts b/protographic/src/naming-conventions.ts index aad58da802..1c83b97f03 100644 --- a/protographic/src/naming-conventions.ts +++ b/protographic/src/naming-conventions.ts @@ -53,9 +53,11 @@ export function createResponseMessageName(methodName: string): string { export function createEntityLookupMethodName(typeName: string, keyString: string = 'id'): string { const keyPart = keyString .trim() - .replace(/,|\s\s/g, ' ') + .replace(/[,\s]+/g, ' ') .split(' ') + .filter((field) => field.length > 0) .map((field) => upperFirst(camelCase(field))) + .sort() .join('And'); return `Lookup${typeName}By${keyPart}`; diff --git a/protographic/src/sdl-to-proto-visitor.ts b/protographic/src/sdl-to-proto-visitor.ts index 5e8a46a583..3c4d289cae 100644 --- a/protographic/src/sdl-to-proto-visitor.ts +++ b/protographic/src/sdl-to-proto-visitor.ts @@ -555,30 +555,43 @@ export class GraphQLToProtoTextVisitor { // Queue this type for message generation (only once) this.queueTypeForProcessing(type); - // Process each @key directive separately + // Normalize keys by sorting fields alphabetically and deduplicating + + const normalizedKeysSet = new Set(); for (const keyDirective of keyDirectives) { const keyString = this.getKeyFromDirective(keyDirective); - if (keyString) { - const methodName = createEntityLookupMethodName(typeName, keyString); - - const requestName = createRequestMessageName(methodName); - const responseName = createResponseMessageName(methodName); - - // Add method name and RPC method with description from the entity type - result.methodNames.push(methodName); - const keyFields = keyString.split(' '); - const keyDescription = keyFields.length === 1 ? keyFields[0] : keyFields.join(' and '); - const description = `Lookup ${typeName} entity by ${keyDescription}${ - type.description ? ': ' + type.description : '' - }`; - result.rpcMethods.push(this.createRpcMethod(methodName, requestName, responseName, description)); - - // Create request and response messages for this key combination - result.messageDefinitions.push( - ...this.createKeyRequestMessage(typeName, requestName, keyString, responseName), - ); - result.messageDefinitions.push(...this.createKeyResponseMessage(typeName, responseName, requestName)); - } + if (!keyString) continue; + + const normalizedKey = keyString + .trim() + .split(/[,\s]+/) + .sort() + .join(' '); + + normalizedKeysSet.add(normalizedKey); + } + + // Process each normalized key + for (const normalizedKeyString of normalizedKeysSet) { + const methodName = createEntityLookupMethodName(typeName, normalizedKeyString); + + const requestName = createRequestMessageName(methodName); + const responseName = createResponseMessageName(methodName); + + // Add method name and RPC method with description from the entity type + result.methodNames.push(methodName); + const keyFields = normalizedKeyString.split(' '); + const keyDescription = keyFields.length === 1 ? keyFields[0] : keyFields.join(' and '); + const description = `Lookup ${typeName} entity by ${keyDescription}${ + type.description ? ': ' + type.description : '' + }`; + result.rpcMethods.push(this.createRpcMethod(methodName, requestName, responseName, description)); + + // Create request and response messages for this key combination + result.messageDefinitions.push( + ...this.createKeyRequestMessage(typeName, requestName, normalizedKeyString, responseName), + ); + result.messageDefinitions.push(...this.createKeyResponseMessage(typeName, responseName, requestName)); } } } diff --git a/protographic/tests/sdl-to-mapping/03-federation.test.ts b/protographic/tests/sdl-to-mapping/03-federation.test.ts index b5b51e46de..a7cfe6d1cf 100644 --- a/protographic/tests/sdl-to-mapping/03-federation.test.ts +++ b/protographic/tests/sdl-to-mapping/03-federation.test.ts @@ -726,7 +726,7 @@ describe('GraphQL Federation to Proto Mapping', () => { const sdl = ` directive @key(fields: String!) on OBJECT - type OrderItem @key(fields: "orderId itemId") { + type OrderItem @key(fields: "orderId itemId") @key(fields: "itemId orderId") { orderId: ID! itemId: ID! quantity: Int! @@ -746,9 +746,17 @@ describe('GraphQL Federation to Proto Mapping', () => { { "key": "orderId itemId", "kind": "entity", - "request": "LookupOrderItemByOrderIdAndItemIdRequest", - "response": "LookupOrderItemByOrderIdAndItemIdResponse", - "rpc": "LookupOrderItemByOrderIdAndItemId", + "request": "LookupOrderItemByItemIdAndOrderIdRequest", + "response": "LookupOrderItemByItemIdAndOrderIdResponse", + "rpc": "LookupOrderItemByItemIdAndOrderId", + "typeName": "OrderItem", + }, + { + "key": "itemId orderId", + "kind": "entity", + "request": "LookupOrderItemByItemIdAndOrderIdRequest", + "response": "LookupOrderItemByItemIdAndOrderIdResponse", + "rpc": "LookupOrderItemByItemIdAndOrderId", "typeName": "OrderItem", }, ], diff --git a/protographic/tests/sdl-to-proto/04-federation.test.ts b/protographic/tests/sdl-to-proto/04-federation.test.ts index 891745bf87..43d9f6bb1b 100644 --- a/protographic/tests/sdl-to-proto/04-federation.test.ts +++ b/protographic/tests/sdl-to-proto/04-federation.test.ts @@ -166,32 +166,32 @@ describe('SDL to Proto - Federation and Special Types', () => { // Service definition for DefaultService service DefaultService { - // Lookup OrderItem entity by orderId and itemId - rpc LookupOrderItemByOrderIdAndItemId(LookupOrderItemByOrderIdAndItemIdRequest) returns (LookupOrderItemByOrderIdAndItemIdResponse) {} + // Lookup OrderItem entity by itemId and orderId + rpc LookupOrderItemByItemIdAndOrderId(LookupOrderItemByItemIdAndOrderIdRequest) returns (LookupOrderItemByItemIdAndOrderIdResponse) {} rpc QueryOrderItem(QueryOrderItemRequest) returns (QueryOrderItemResponse) {} } // Key message for OrderItem entity lookup - message LookupOrderItemByOrderIdAndItemIdRequestKey { + message LookupOrderItemByItemIdAndOrderIdRequestKey { // Key field for OrderItem entity lookup. - string order_id = 1; + string item_id = 1; // Key field for OrderItem entity lookup. - string item_id = 2; + string order_id = 2; } // Request message for OrderItem entity lookup. - message LookupOrderItemByOrderIdAndItemIdRequest { + message LookupOrderItemByItemIdAndOrderIdRequest { /* * List of keys to look up OrderItem entities. - * Order matters - each key maps to one entity in LookupOrderItemByOrderIdAndItemIdResponse. + * Order matters - each key maps to one entity in LookupOrderItemByItemIdAndOrderIdResponse. */ - repeated LookupOrderItemByOrderIdAndItemIdRequestKey keys = 1; + repeated LookupOrderItemByItemIdAndOrderIdRequestKey keys = 1; } // Response message for OrderItem entity lookup. - message LookupOrderItemByOrderIdAndItemIdResponse { + message LookupOrderItemByItemIdAndOrderIdResponse { /* - * List of OrderItem entities in the same order as the keys in LookupOrderItemByOrderIdAndItemIdRequest. + * List of OrderItem entities in the same order as the keys in LookupOrderItemByItemIdAndOrderIdRequest. * Always return the same number of entities as keys. Use null for entities that cannot be found. * * Example: @@ -560,7 +560,7 @@ describe('SDL to Proto - Federation and Special Types', () => { const sdl = ` directive @key(fields: String!) on OBJECT | INTERFACE - type OrderItem @key(fields: "orderId itemId") { + type OrderItem @key(fields: "orderId itemId") @key(fields: "itemId orderId") { orderId: ID! itemId: ID! quantity: Int! @@ -584,32 +584,32 @@ describe('SDL to Proto - Federation and Special Types', () => { // Service definition for DefaultService service DefaultService { - // Lookup OrderItem entity by orderId and itemId - rpc LookupOrderItemByOrderIdAndItemId(LookupOrderItemByOrderIdAndItemIdRequest) returns (LookupOrderItemByOrderIdAndItemIdResponse) {} + // Lookup OrderItem entity by itemId and orderId + rpc LookupOrderItemByItemIdAndOrderId(LookupOrderItemByItemIdAndOrderIdRequest) returns (LookupOrderItemByItemIdAndOrderIdResponse) {} rpc QueryOrderItems(QueryOrderItemsRequest) returns (QueryOrderItemsResponse) {} } // Key message for OrderItem entity lookup - message LookupOrderItemByOrderIdAndItemIdRequestKey { + message LookupOrderItemByItemIdAndOrderIdRequestKey { // Key field for OrderItem entity lookup. - string order_id = 1; + string item_id = 1; // Key field for OrderItem entity lookup. - string item_id = 2; + string order_id = 2; } // Request message for OrderItem entity lookup. - message LookupOrderItemByOrderIdAndItemIdRequest { + message LookupOrderItemByItemIdAndOrderIdRequest { /* * List of keys to look up OrderItem entities. - * Order matters - each key maps to one entity in LookupOrderItemByOrderIdAndItemIdResponse. + * Order matters - each key maps to one entity in LookupOrderItemByItemIdAndOrderIdResponse. */ - repeated LookupOrderItemByOrderIdAndItemIdRequestKey keys = 1; + repeated LookupOrderItemByItemIdAndOrderIdRequestKey keys = 1; } // Response message for OrderItem entity lookup. - message LookupOrderItemByOrderIdAndItemIdResponse { + message LookupOrderItemByItemIdAndOrderIdResponse { /* - * List of OrderItem entities in the same order as the keys in LookupOrderItemByOrderIdAndItemIdRequest. + * List of OrderItem entities in the same order as the keys in LookupOrderItemByItemIdAndOrderIdRequest. * Always return the same number of entities as keys. Use null for entities that cannot be found. * * Example: From 208679438cbccc714bb621f41712c298cd96be9f Mon Sep 17 00:00:00 2001 From: endigma Date: Wed, 6 Aug 2025 11:04:44 +0100 Subject: [PATCH 6/6] Clean up and test key normalization --- protographic/src/naming-conventions.ts | 8 +- protographic/src/sdl-to-proto-visitor.ts | 2 +- .../sdl-to-mapping/03-federation.test.ts | 90 +++++++++++++++++++ .../tests/sdl-to-proto/04-federation.test.ts | 86 ++++++++++++++++++ 4 files changed, 180 insertions(+), 6 deletions(-) diff --git a/protographic/src/naming-conventions.ts b/protographic/src/naming-conventions.ts index 1c83b97f03..2652bd7511 100644 --- a/protographic/src/naming-conventions.ts +++ b/protographic/src/naming-conventions.ts @@ -51,16 +51,14 @@ export function createResponseMessageName(methodName: string): string { * Creates an entity lookup method name for an entity type */ export function createEntityLookupMethodName(typeName: string, keyString: string = 'id'): string { - const keyPart = keyString - .trim() - .replace(/[,\s]+/g, ' ') - .split(' ') + const normalizedKey = keyString + .split(/[,\s]+/) .filter((field) => field.length > 0) .map((field) => upperFirst(camelCase(field))) .sort() .join('And'); - return `Lookup${typeName}By${keyPart}`; + return `Lookup${typeName}By${normalizedKey}`; } /** diff --git a/protographic/src/sdl-to-proto-visitor.ts b/protographic/src/sdl-to-proto-visitor.ts index 3c4d289cae..fd9bc4f6d3 100644 --- a/protographic/src/sdl-to-proto-visitor.ts +++ b/protographic/src/sdl-to-proto-visitor.ts @@ -563,8 +563,8 @@ export class GraphQLToProtoTextVisitor { if (!keyString) continue; const normalizedKey = keyString - .trim() .split(/[,\s]+/) + .filter((field) => field.length > 0) .sort() .join(' '); diff --git a/protographic/tests/sdl-to-mapping/03-federation.test.ts b/protographic/tests/sdl-to-mapping/03-federation.test.ts index a7cfe6d1cf..c07a92519d 100644 --- a/protographic/tests/sdl-to-mapping/03-federation.test.ts +++ b/protographic/tests/sdl-to-mapping/03-federation.test.ts @@ -896,4 +896,94 @@ describe('GraphQL Federation to Proto Mapping', () => { } `); }); + + it('maps entity with mixed multiple and compound keys with commas and extra spaces', () => { + const sdl = ` + directive @key(fields: String!) on OBJECT + + type Product @key(fields: "id") @key(fields: "manufacturerId, productCode") { + id: ID! + manufacturerId: ID! + productCode: String! + name: String! + price: Float! + } + + type Query { + products: [Product!]! + } + `; + + const mapping = compileGraphQLToMapping(sdl, 'ProductService'); + + expect(mapping.toJson()).toMatchInlineSnapshot(` + { + "entityMappings": [ + { + "key": "id", + "kind": "entity", + "request": "LookupProductByIdRequest", + "response": "LookupProductByIdResponse", + "rpc": "LookupProductById", + "typeName": "Product", + }, + { + "key": "manufacturerId, productCode", + "kind": "entity", + "request": "LookupProductByManufacturerIdAndProductCodeRequest", + "response": "LookupProductByManufacturerIdAndProductCodeResponse", + "rpc": "LookupProductByManufacturerIdAndProductCode", + "typeName": "Product", + }, + ], + "operationMappings": [ + { + "mapped": "QueryProducts", + "original": "products", + "request": "QueryProductsRequest", + "response": "QueryProductsResponse", + "type": "OPERATION_TYPE_QUERY", + }, + ], + "service": "ProductService", + "typeFieldMappings": [ + { + "fieldMappings": [ + { + "mapped": "products", + "original": "products", + }, + ], + "type": "Query", + }, + { + "fieldMappings": [ + { + "mapped": "id", + "original": "id", + }, + { + "mapped": "manufacturer_id", + "original": "manufacturerId", + }, + { + "mapped": "product_code", + "original": "productCode", + }, + { + "mapped": "name", + "original": "name", + }, + { + "mapped": "price", + "original": "price", + }, + ], + "type": "Product", + }, + ], + "version": 1, + } + `); + }); }); diff --git a/protographic/tests/sdl-to-proto/04-federation.test.ts b/protographic/tests/sdl-to-proto/04-federation.test.ts index 43d9f6bb1b..2ec9f0d585 100644 --- a/protographic/tests/sdl-to-proto/04-federation.test.ts +++ b/protographic/tests/sdl-to-proto/04-federation.test.ts @@ -642,6 +642,92 @@ describe('SDL to Proto - Federation and Special Types', () => { `); }); + test('should handle entity types with proper compound key fields with extra commas and spaces', () => { + const sdl = ` + directive @key(fields: String!) on OBJECT | INTERFACE + + type OrderItem @key(fields: " ,orderId, itemId, ") { + orderId: ID! + itemId: ID! + quantity: Int! + price: Float! + } + + type Query { + orderItems: [OrderItem!]! + } + `; + + const { proto: protoText } = compileGraphQLToProto(sdl); + + // Validate Proto definition + expectValidProto(protoText); + + // Check that compound key lookup with both fields is present + expect(protoText).toMatchInlineSnapshot(` + "syntax = "proto3"; + package service.v1; + + // Service definition for DefaultService + service DefaultService { + // Lookup OrderItem entity by itemId and orderId + rpc LookupOrderItemByItemIdAndOrderId(LookupOrderItemByItemIdAndOrderIdRequest) returns (LookupOrderItemByItemIdAndOrderIdResponse) {} + rpc QueryOrderItems(QueryOrderItemsRequest) returns (QueryOrderItemsResponse) {} + } + + // Key message for OrderItem entity lookup + message LookupOrderItemByItemIdAndOrderIdRequestKey { + // Key field for OrderItem entity lookup. + string item_id = 1; + // Key field for OrderItem entity lookup. + string order_id = 2; + } + + // Request message for OrderItem entity lookup. + message LookupOrderItemByItemIdAndOrderIdRequest { + /* + * List of keys to look up OrderItem entities. + * Order matters - each key maps to one entity in LookupOrderItemByItemIdAndOrderIdResponse. + */ + repeated LookupOrderItemByItemIdAndOrderIdRequestKey keys = 1; + } + + // Response message for OrderItem entity lookup. + message LookupOrderItemByItemIdAndOrderIdResponse { + /* + * List of OrderItem entities in the same order as the keys in LookupOrderItemByItemIdAndOrderIdRequest. + * Always return the same number of entities as keys. Use null for entities that cannot be found. + * + * Example: + * LookupUserByIdRequest: + * keys: + * - id: 1 + * - id: 2 + * LookupUserByIdResponse: + * result: + * - id: 1 # User with id 1 found + * - null # User with id 2 not found + */ + repeated OrderItem result = 1; + } + + // Request message for orderItems operation. + message QueryOrderItemsRequest { + } + // Response message for orderItems operation. + message QueryOrderItemsResponse { + repeated OrderItem order_items = 1; + } + + message OrderItem { + string order_id = 1; + string item_id = 2; + int32 quantity = 3; + double price = 4; + }" + `); + }); + test('should handle entity types with mixed multiple and compound keys', () => { const sdl = ` directive @key(fields: String!) on OBJECT | INTERFACE