Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
a8790be
feat: add support for @requires
Noroth Jan 5, 2026
2fcd43b
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Jan 5, 2026
0fb9fb5
feat: include logic for requires in the proto generator
Noroth Jan 5, 2026
a9b5873
chore: lint format
Noroth Jan 5, 2026
3af2531
chore: add more tests and prepare abstract selection rewriting
Noroth Jan 7, 2026
90b89a7
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Jan 7, 2026
8b36ed9
chore: add mapping and validation
Noroth Jan 19, 2026
fa7c602
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Jan 19, 2026
925740b
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Jan 27, 2026
03070da
chore: improve the mapping
Noroth Feb 3, 2026
d4abf0b
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 3, 2026
993c2ba
chore: add comments
Noroth Feb 3, 2026
d8dd473
chore: export constants
Noroth Feb 3, 2026
977e1fd
chore: update import
Noroth Feb 3, 2026
62e44a2
normalize keys
Noroth Feb 3, 2026
5cd87b4
chore: apply normalization logic from composition
Noroth Feb 3, 2026
576294e
chore: updates
Noroth Feb 3, 2026
a7b4e9b
chore: mimimi
Noroth Feb 3, 2026
f711133
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 3, 2026
6dc6997
chore: do not duplicate messages
Noroth Feb 3, 2026
1974e6d
chore: remove unused options
Noroth Feb 3, 2026
506dd1a
chore: pr reviews
Noroth Feb 3, 2026
20b6f8c
chore: do not use type assertio9n
Noroth Feb 3, 2026
f9281b7
chore: remove unused function
Noroth Feb 3, 2026
01f16fc
chore: return early
Noroth Feb 3, 2026
9bac9ee
chore: import
Noroth Feb 3, 2026
0a22c6b
chore: install dependencies in related projects
Noroth Feb 3, 2026
02de360
chore: imports
Noroth Feb 3, 2026
5a40d18
chore: mroe cleanup
Noroth Feb 3, 2026
59f92b4
refactor: enable eslint to comply with rules in other packages
Noroth Feb 3, 2026
5a040a2
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 3, 2026
c8b3469
chore: more lint updates
Noroth Feb 3, 2026
4174625
chore: sanitize comments
Noroth Feb 3, 2026
cb8ee46
chore: update comment
Noroth Feb 3, 2026
90a5b67
chore: properly handle composite types on field level
Noroth Feb 4, 2026
9af0929
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 4, 2026
b0b4390
chore: lint
Noroth Feb 4, 2026
20ca49d
chore: balance pop
Noroth Feb 4, 2026
d9169fb
chore: add eslint dependency
Noroth Feb 4, 2026
a7264e3
chore: make composite member type order deterministic
Noroth Feb 4, 2026
43ab1a7
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 5, 2026
9378483
chore: implement abstract selection rewriting
Noroth Feb 6, 2026
2b0b1d6
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 6, 2026
94fff18
chore: index won't be undefined
Noroth Feb 6, 2026
c82e9ba
chore: fix typo
Noroth Feb 6, 2026
179d3a7
chore: remove duplicated type
Noroth Feb 6, 2026
a64c780
chore: remove other duplicate map
Noroth Feb 6, 2026
4e53b55
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 6, 2026
5517a99
chore: fixes
Noroth Feb 6, 2026
95a0871
chore: implement recursive handling for rewriting subselections
Noroth Feb 6, 2026
712b2e9
chore: add more tests
Noroth Feb 6, 2026
d09ff5a
chore: nested inline fragments should only allow parent resolution
Noroth Feb 6, 2026
5dd480f
chore: more tests
Noroth Feb 6, 2026
b1116b9
feat: implement support for deeply nested interface combinations
Noroth Feb 9, 2026
486e70d
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 9, 2026
5a5b4d2
chore: target interface root to determine valid implementation types
Noroth Feb 9, 2026
d16e439
chore: remove typed return
Noroth Feb 9, 2026
e681144
chore: handle simple union normalization
Noroth Feb 9, 2026
7a538a8
chore: improve rewriting logic
Noroth Feb 10, 2026
460a9d9
chore: handle nested concrete fragments and unions
Noroth Feb 10, 2026
f123e53
Merge branch 'main' of github.com:wundergraph/cosmo into ludwig/eng-8…
Noroth Feb 10, 2026
ffb1a86
chore: update composition-go
Noroth Feb 10, 2026
ac580e0
chore: add requires docs to markdown file
Noroth Feb 12, 2026
baf5dcb
Merge branch 'main' of github.com:wundergraph/cosmo into ludwig/eng-8…
Noroth Feb 13, 2026
7e69bc1
chore: update composition-go
Noroth Feb 13, 2026
cbf8280
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 13, 2026
08c0176
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 23, 2026
e26a19d
chore: lint fixes
Noroth Feb 23, 2026
f2eeb71
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 23, 2026
1ced10e
chore: remove obsolete code
Noroth Feb 24, 2026
9fd085d
chore: add test for nested composite type
Noroth Feb 24, 2026
0762cdd
chore: updates from PR reviews
Noroth Feb 24, 2026
491848f
Merge branch 'main' of github.com:wundergraph/cosmo into ludwig/eng-8…
Noroth Feb 24, 2026
2134831
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 24, 2026
3f17925
Merge branch 'main' into ludwig/eng-8641-implement-proto-generation-i…
Noroth Feb 26, 2026
bdec880
chore: resolve PR comments
Noroth Feb 26, 2026
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,171 changes: 643 additions & 528 deletions connect-go/gen/proto/wg/cosmo/node/v1/node.pb.go

Large diffs are not rendered by default.

71 changes: 71 additions & 0 deletions connect/src/wg/cosmo/node/v1/node_pb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2095,6 +2095,13 @@ export class EntityMapping extends Message<EntityMapping> {
*/
response = "";

/**
* Mappings for required fields
*
* @generated from field: repeated wg.cosmo.node.v1.RequiredFieldMapping required_field_mappings = 7;
*/
requiredFieldMappings: RequiredFieldMapping[] = [];

constructor(data?: PartialMessage<EntityMapping>) {
super();
proto3.util.initPartial(data, this);
Expand All @@ -2109,6 +2116,7 @@ export class EntityMapping extends Message<EntityMapping> {
{ no: 4, name: "rpc", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 5, name: "request", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 6, name: "response", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 7, name: "required_field_mappings", kind: "message", T: RequiredFieldMapping, repeated: true },
]);

static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): EntityMapping {
Expand All @@ -2128,6 +2136,69 @@ export class EntityMapping extends Message<EntityMapping> {
}
}

/**
* Defines mapping for required fields
*
* @generated from message wg.cosmo.node.v1.RequiredFieldMapping
*/
export class RequiredFieldMapping extends Message<RequiredFieldMapping> {
/**
* @generated from field: wg.cosmo.node.v1.FieldMapping field_mapping = 1;
*/
fieldMapping?: FieldMapping;

/**
* Mapped gRPC method name
*
* @generated from field: string rpc = 2;
*/
rpc = "";

/**
* gRPC request message type name
*
* @generated from field: string request = 3;
*/
request = "";

/**
* gRPC response message type name
*
* @generated from field: string response = 4;
*/
response = "";

constructor(data?: PartialMessage<RequiredFieldMapping>) {
super();
proto3.util.initPartial(data, this);
}

static readonly runtime: typeof proto3 = proto3;
static readonly typeName = "wg.cosmo.node.v1.RequiredFieldMapping";
static readonly fields: FieldList = proto3.util.newFieldList(() => [
{ no: 1, name: "field_mapping", kind: "message", T: FieldMapping },
{ no: 2, name: "rpc", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 3, name: "request", kind: "scalar", T: 9 /* ScalarType.STRING */ },
{ no: 4, name: "response", kind: "scalar", T: 9 /* ScalarType.STRING */ },
]);

static fromBinary(bytes: Uint8Array, options?: Partial<BinaryReadOptions>): RequiredFieldMapping {
return new RequiredFieldMapping().fromBinary(bytes, options);
}

static fromJson(jsonValue: JsonValue, options?: Partial<JsonReadOptions>): RequiredFieldMapping {
return new RequiredFieldMapping().fromJson(jsonValue, options);
}

static fromJsonString(jsonString: string, options?: Partial<JsonReadOptions>): RequiredFieldMapping {
return new RequiredFieldMapping().fromJsonString(jsonString, options);
}

static equals(a: RequiredFieldMapping | PlainMessage<RequiredFieldMapping> | undefined, b: RequiredFieldMapping | PlainMessage<RequiredFieldMapping> | undefined): boolean {
return proto3.util.equals(RequiredFieldMapping, a, b);
}
}

/**
* Defines mapping between GraphQL type fields and gRPC message fields
*
Expand Down
13 changes: 13 additions & 0 deletions proto/wg/cosmo/node/v1/node.proto
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,19 @@ message EntityMapping {
string request = 5;
// gRPC response message type name
string response = 6;
// Mappings for required fields
repeated RequiredFieldMapping required_field_mappings = 7;
}

// Defines mapping for required fields
message RequiredFieldMapping {
FieldMapping field_mapping = 1;
// Mapped gRPC method name
string rpc = 2;
// gRPC request message type name
string request = 3;
// gRPC response message type name
string response = 4;
}

// Defines mapping between GraphQL type fields and gRPC message fields
Expand Down
193 changes: 193 additions & 0 deletions protographic/src/abstract-selection-rewriter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* @file abstract-selection-rewriter.ts
*
* This module provides functionality to normalize GraphQL field set selections
* when dealing with abstract types (interfaces). It ensures that fields selected
* at the interface level are properly distributed into each inline fragment,
* maintaining correct selection semantics for proto mapping generation.
*/

import {
ASTVisitor,
DocumentNode,
GraphQLSchema,
GraphQLObjectType,
visit,
SelectionSetNode,
isInterfaceType,
Kind,
FieldNode,
ASTNode,
GraphQLField,
GraphQLType,
getNamedType,
} from 'graphql';
import { VisitContext } from './types';

/**
* Rewrites GraphQL selection sets to normalize abstract type selections.
*
* When a field returns an interface type, selections can be made both at the
* interface level and within inline fragments for concrete types. This class
* normalizes such selections by moving interface-level fields into each inline
* fragment, ensuring consistent selection structure for downstream processing.
*
* @example
* Input selection:
* ```graphql
* media {
* id # interface-level field
* ... on Book { title }
* ... on Movie { duration }
* }
* ```
*
* Output after normalization:
* ```graphql
* media {
* ... on Book { id title }
* ... on Movie { id duration }
* }
* ```
*/
export class AbstractSelectionRewriter {
private readonly visitor: ASTVisitor;
private readonly fieldSetDoc: DocumentNode;
public readonly schema: GraphQLSchema;
private currentType: GraphQLObjectType;

/**
* Creates a new AbstractSelectionRewriter instance.
*
* @param fieldSetDoc - The parsed GraphQL document containing the field set to rewrite
* @param schema - The GraphQL schema used for type resolution
* @param objectType - The root object type where the field set originates
*/
constructor(fieldSetDoc: DocumentNode, schema: GraphQLSchema, objectType: GraphQLObjectType) {
this.fieldSetDoc = fieldSetDoc;
this.schema = schema;
this.currentType = objectType;
this.visitor = this.createASTVisitor();
}

/**
* Creates the AST visitor that processes selection sets during traversal.
*
* @returns An ASTVisitor configured to handle SelectionSet nodes
*/
private createASTVisitor(): ASTVisitor {
return {
SelectionSet: {
enter: (node, key, parent, path, ancestors) => {
this.onEnterSelectionSet({ node, key, parent, path, ancestors });
},
},
};
}

/**
* Executes the normalization process on the field set document.
*
* This method traverses the AST and rewrites any selection sets that target
* interface types, distributing interface-level fields into inline fragments.
* The modification is performed in-place on the provided document.
*/
public normalize(): void {
visit(this.fieldSetDoc, this.visitor);
}

/**
* Handles the entry into a SelectionSet node during AST traversal.
*
* If the selection set's parent field returns an interface type, this method:
* 1. Extracts all direct field selections (interface-level fields)
* 2. Removes them from the selection set, leaving only inline fragments
* 3. Prepends the interface-level fields to each inline fragment's selections
* (unless the fragment already contains that field)
*
* @param ctx - The visitor context containing the current node and its position in the AST
*/
private onEnterSelectionSet(ctx: VisitContext<SelectionSetNode>): void {
if (!ctx.parent) return;
if (!this.isFieldNode(ctx.parent)) return;

const currentType = this.findNamedTypeForField(ctx.parent.name.value);
if (!currentType) return;

// Only process selection sets for interface types
if (!isInterfaceType(currentType)) {
return;
}

const fields = ctx.node.selections.filter((s) => s.kind === Kind.FIELD);
const inlineFragments = ctx.node.selections.filter((s) => s.kind === Kind.INLINE_FRAGMENT);

// Remove the interface-level fields from the selection set, keeping only inline fragments
ctx.node.selections = [...inlineFragments];

// Distribute interface-level fields into each inline fragment
for (const fragment of inlineFragments) {
const normalizedFields = fragment.selectionSet.selections.filter((s) => s.kind === Kind.FIELD) ?? [];

for (const field of fields) {
// Skip if the fragment already has this field to avoid duplicates
if (this.hasField(normalizedFields, field.name.value)) {
continue;
}

normalizedFields.unshift(field);
}

fragment.selectionSet.selections = [...normalizedFields];
}
}

/**
* Checks if a field with the given name exists in the provided field array.
*
* @param fields - Array of FieldNode objects to search
* @param fieldName - The name of the field to look for
* @returns true if a field with the given name exists, false otherwise
*/
private hasField(fields: FieldNode[], fieldName: string): boolean {
return fields.some((f) => f.name.value === fieldName);
}

/**
* Type guard to check if an AST node is a FieldNode.
*
* @param node - The AST node or array of nodes to check
* @returns true if the node is a FieldNode, false otherwise
*/
private isFieldNode(node: ASTNode | ReadonlyArray<ASTNode>): node is FieldNode {
if (Array.isArray(node)) return false;
return (node as ASTNode).kind === Kind.FIELD;
}

/**
* Retrieves the field definition for a given field name from the current type.
*
* @param fieldName - The name of the field to look up
* @returns The GraphQL field definition, or undefined if not found
*/
private fieldDefinition(fieldName: string): GraphQLField<any, any, any> | undefined {
return this.currentType.getFields()[fieldName];
}

/**
* Finds the named (unwrapped) type for a field by its name.
*
* This method looks up the field in the current type's fields and returns
* the named type (stripping away any List or NonNull wrappers).
*
* @param fieldName - The name of the field to look up
* @returns The named GraphQL type, or undefined if the field doesn't exist
*/
private findNamedTypeForField(fieldName: string): GraphQLType | undefined {
const fields = this.currentType.getFields();
const field = fields[fieldName];
if (!field) return undefined;

return getNamedType(field.type);
}
}
12 changes: 12 additions & 0 deletions protographic/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export function validateGraphQLSDL(sdl: string): ValidationResult {

export * from './sdl-to-mapping-visitor.js';
export { GraphQLToProtoTextVisitor } from './sdl-to-proto-visitor.js';
export { RequiredFieldsVisitor } from './required-fields-visitor.js';
export { ProtoLockManager } from './proto-lock.js';
export { SDLValidationVisitor } from './sdl-validation-visitor.js';

Expand Down Expand Up @@ -149,5 +150,16 @@ export {
OperationType,
} from '@wundergraph/cosmo-connect/dist/node/v1/node_pb';

export {
CONNECT_FIELD_RESOLVER,
CONTEXT,
EXTERNAL_DIRECTIVE_NAME,
FIELDS,
FIELD_ARGS,
KEY_DIRECTIVE_NAME,
REQUIRES_DIRECTIVE_NAME,
RESULT,
} from './string-constants.js';

// Export protobufjs for AST manipulation
export { default as protobuf } from 'protobufjs';
39 changes: 38 additions & 1 deletion protographic/src/naming-conventions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,51 @@ 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 normalizedKey = createMethodSuffixFromEntityKey(keyString);
return `Lookup${typeName}${normalizedKey}`;
}

/**
* Creates a key message name for an entity lookup request
* @param typeName - The name of the entity type
* @param keyString - The key string
* @returns The name of the key message
*/
export function createEntityLookupRequestKeyMessageName(typeName: string, keyString: string = 'id'): string {
const requestName = createRequestMessageName(createEntityLookupMethodName(typeName, keyString));
return `${requestName}Key`;
}

/**
* Creates a required fields method name for an entity type
* @param typeName - The name of the entity type
* @param fieldName - The name of the field that is required
* @param keyString - The key string
* @returns The name of the required fields method
* @example
* createRequiredFieldsMethodName('User', 'post', 'id') // => 'RequireUserPostById'
* createRequiredFieldsMethodName('User', 'post', 'id name') // => 'RequireUserPostByIdAndName'
* createRequiredFieldsMethodName('User', 'post', 'name,id') // => 'RequireUserPostByNameAndId'
*/
export function createRequiredFieldsMethodName(typeName: string, fieldName: string, keyString: string = 'id'): string {
const normalizedKey = createMethodSuffixFromEntityKey(keyString);
return `Require${typeName}${upperFirst(camelCase(fieldName))}${normalizedKey}`;
}

/**
* Creates a method suffix from an entity key string
* @param keyString - The key string
* @returns The method suffix
*/
export function createMethodSuffixFromEntityKey(keyString: string = 'id'): string {
const normalizedKey = keyString
.split(/[,\s]+/)
.filter((field) => field.length > 0)
.map((field) => upperFirst(camelCase(field)))
.sort()
.join('And');

return `Lookup${typeName}By${normalizedKey}`;
return `By${normalizedKey}`;
}

/**
Expand Down
Loading
Loading