Skip to content

Commit d10c29e

Browse files
authored
Merge pull request #155 from grafbase/tomhoule-tlsluvktrpnv
protoc-gen-grafbase-subgraph: proper @derive support
2 parents aa2b984 + 2586419 commit d10c29e

25 files changed

Lines changed: 3088 additions & 89 deletions

Cargo.lock

Lines changed: 1669 additions & 69 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ clap = "4.5.36"
6363
ctor = "0.4"
6464
duration-str = "0.17"
6565
enumflags2 = "0.7.11"
66+
field-selection-map = { git = "https://github.com/grafbase/grafbase", package = "engine-field-selection-map" }
6667
futures = "0.3"
6768
futures-util = "0.3.31"
6869
fxhash = "0.2.1"

cli/protoc-gen-grafbase-subgraph/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
### Added
44

5+
- **Input argument directives** added. You can now add GraphQL directives to RPC method input arguments using the `input_argument_directives` option on methods.
6+
7+
- **Composite schema entity references with @derive** added. You can now create federation-style entity references using the `derive` option on messages:
8+
- Use `option (grafbase.graphql.derive) = {entity: "User", is: "{ id: user_id }"};` on fields
9+
- Automatically generates reference fields with `@derive` and `@is` directives
10+
- Creates stub entity types with `@key` directives if the type is not already defined
11+
- Supports custom relation field names with the `field` parameter
12+
- The `is` parameter defines the field mapping using format `"{ <entity_key_field>: <proto_field> }"`
13+
- The `@is` directive uses the value from the `is` parameter directly
14+
- Enables cross-subgraph entity references in federated schemas
15+
516
- **Multiple subgraphs support** added. Support for generating multiple GraphQL files based on service annotations:
617

718
- Services can now have a `subgraph_name` option that maps them to different subgraph files

cli/protoc-gen-grafbase-subgraph/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@ keywords.workspace = true
88
repository.workspace = true
99

1010
[dependencies]
11+
anyhow.workspace = true
12+
field-selection-map.workspace = true
1113
indexmap.workspace = true
1214
paste.workspace = true
1315
protobuf.workspace = true
1416
protobuf-support.workspace = true
1517

18+
[build-dependencies]
19+
protobuf-codegen = "3"
20+
1621
[dev-dependencies]
1722
insta.workspace = true
1823
tempfile.workspace = true

cli/protoc-gen-grafbase-subgraph/README.md

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,13 @@ import "grafbase/options.proto";
106106
107107
service MyService {
108108
option (grafbase.graphql.schema_directives) = "@contact(name: \"API Support\", url: \"https://api.example.com/support\")";
109-
109+
110110
rpc GetItem(GetItemRequest) returns (Item);
111111
}
112112
113113
service AnotherService {
114114
option (grafbase.graphql.schema_directives) = "@tag(name: \"backend\") @auth(rules: [{allow: \"admin\"}])";
115-
115+
116116
rpc UpdateItem(UpdateItemRequest) returns (Item);
117117
}
118118
```
@@ -133,6 +133,69 @@ extend schema
133133
- **Multi-file mode**: When using `subgraph_name`, only directives from services in that subgraph are included
134134
- **Multiple directives**: You can include multiple directives in a single string, separated by spaces
135135

136+
### Composite schema entity references
137+
138+
You can create federation-style entity references using the `derive` option on message fields. This allows you to reference entities from other subgraphs:
139+
140+
```protobuf
141+
import "grafbase/options.proto";
142+
143+
message Product {
144+
// Basic usage: creates a user field that references User entity by id
145+
(grafbase.graphql.derive) = {
146+
entity: "User",
147+
is: "{ id: user_id }"
148+
};
149+
150+
// Custom relation field name: creates an owner field instead of user
151+
(grafbase.graphql.derive) = {
152+
entity: "User",
153+
field: "owner"
154+
is: "{ id: owner_id }"
155+
};
156+
157+
// Reference by non-id field
158+
(grafbase.graphql.composite_schemas_entity) = {
159+
entity: "Shop",
160+
is: "{ slug: shop_slug }"
161+
};
162+
163+
string id = 1;
164+
string name = 2;
165+
166+
string user_id = 3;
167+
string owner_id = 4;
168+
169+
string shop_slug = 6;
170+
}
171+
```
172+
173+
This generates GraphQL with entity references:
174+
175+
```graphql
176+
type Product {
177+
id: String
178+
name: String
179+
user_id: String
180+
user: User @derive @is(field: "{ id: user_id }") # Automatically added reference field
181+
owner_id: String
182+
owner: User @derive @is(field: "{ id: owner_id }") # Custom name for the reference
183+
shop_slug: String
184+
shop: Shop @derive @is(field: "{ slug: shop_slug }")
185+
}
186+
187+
# Stub entities are automatically created
188+
type User @key(fields: "id") {
189+
id: String
190+
}
191+
192+
type Shop @key(fields: "slug") {
193+
slug: String
194+
}
195+
```
196+
197+
Composite (multiple fields) and list derives are also supported.
198+
136199
### Mapping specific services to different subgraphs
137200

138201
By default, the plugin generates a single `schema.graphql` file containing all services. However, you can map different services to different subgraph files using the `subgraph_name` option:
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
use std::io::Result;
2+
3+
fn main() -> Result<()> {
4+
protobuf_codegen::Codegen::new()
5+
.pure()
6+
.cargo_out_dir("protos")
7+
.include("proto")
8+
.input("proto/grafbase/options.proto")
9+
.run_from_script();
10+
11+
Ok(())
12+
}

cli/protoc-gen-grafbase-subgraph/proto/grafbase/options.proto

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@ import "google/protobuf/descriptor.proto";
44

55
package grafbase.graphql;
66

7+
message CompositeSchemaEntity {
8+
optional string entity = 1;
9+
optional string field = 2;
10+
optional string is = 3;
11+
}
12+
713
extend google.protobuf.MessageOptions {
814
optional string object_directives = 58301;
915
optional string input_object_directives = 58302;
16+
repeated CompositeSchemaEntity derive = 58303;
1017
}
1118

1219
extend google.protobuf.FieldOptions {

cli/protoc-gen-grafbase-subgraph/src/main.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod display_utils;
2+
mod options_proto;
23
mod render_graphql_sdl;
34
mod schema;
45
mod translate_schema;
@@ -70,7 +71,8 @@ fn generate(raw_request: &[u8]) -> CodeGeneratorResponse {
7071
// Generate a file for each subgraph
7172
for (subgraph_name, service_ids) in services_by_subgraph {
7273
let mut graphql_schema = String::new();
73-
render_graphql_sdl_filtered(&translated_schema, Some(&service_ids), &mut graphql_schema).unwrap();
74+
render_graphql_sdl_filtered(&translated_schema, Some(&service_ids), &mut graphql_schema)
75+
.expect("Failed to render GraphQL schema");
7476

7577
let mut file = File::new();
7678
file.set_name(format!("{}.graphql", subgraph_name));
@@ -80,7 +82,7 @@ fn generate(raw_request: &[u8]) -> CodeGeneratorResponse {
8082
} else {
8183
// Single-file mode: generate schema.graphql
8284
let mut graphql_schema = String::new();
83-
render_graphql_sdl(&translated_schema, &mut graphql_schema).unwrap();
85+
render_graphql_sdl(&translated_schema, &mut graphql_schema).expect("Failed to render GraphQL schema");
8486

8587
let mut file = File::new();
8688
file.set_name("schema.graphql".to_owned());
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include!(concat!(env!("OUT_DIR"), "/protos/mod.rs"));

cli/protoc-gen-grafbase-subgraph/src/render_graphql_sdl/graphql_types.rs

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
use crate::schema::ScalarType;
2-
31
use super::*;
2+
use crate::schema::{ProtoMessage, ScalarType, View};
43

54
pub(super) fn render_graphql_types(
65
schema: &GrpcSchema,
@@ -28,6 +27,8 @@ pub(super) fn render_graphql_types(
2827
render_enum_definition(schema, *enum_id, f)?;
2928
}
3029

30+
render_entity_types(schema, messages_to_render_as_output, f)?;
31+
3132
Ok(())
3233
}
3334

@@ -95,7 +96,7 @@ fn render_message(
9596
if input {
9697
render_input_field_type(schema, &field.r#type, field.repeated, f)?;
9798
} else {
98-
render_output_field_type(schema, &field.r#type, field.repeated, f)?;
99+
render_output_field_type(schema, &field.r#type, field.repeated, true, f)?;
99100
}
100101

101102
let field_directives = if input {
@@ -112,13 +113,54 @@ fn render_message(
112113
f.write_str("\n")?;
113114
}
114115

116+
if !input {
117+
render_derives(message, f)?;
118+
}
119+
115120
f.write_str("}\n")
116121
}
117122

123+
fn render_derives(message: View<'_, ProtoMessageId, ProtoMessage>, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124+
for entity_info in &message.derives {
125+
f.write_str(INDENT)?;
126+
127+
let derived_field_name = if let Some(name) = &entity_info.field {
128+
name.clone()
129+
} else {
130+
entity_info
131+
.is
132+
.as_ref()
133+
.and_then(|is| is.fields.first())
134+
.map(|first_field| {
135+
first_field
136+
.input_field_name
137+
.trim_end_matches("_id")
138+
.trim_end_matches("Id")
139+
.to_owned()
140+
})
141+
.unwrap_or_else(|| entity_info.entity.to_lowercase())
142+
};
143+
144+
f.write_str(&derived_field_name)?;
145+
f.write_str(": ")?;
146+
f.write_str(&entity_info.entity)?;
147+
f.write_str(" @derive")?;
148+
149+
if let Some(is) = &entity_info.is {
150+
write!(f, " @is(field: \"{is}\")")?;
151+
}
152+
153+
f.write_str("\n")?;
154+
}
155+
156+
Ok(())
157+
}
158+
118159
pub(super) fn render_output_field_type(
119160
schema: &GrpcSchema,
120161
ty: &FieldType,
121162
repeated: bool,
163+
optional: bool,
122164
f: &mut fmt::Formatter<'_>,
123165
) -> fmt::Result {
124166
if repeated {
@@ -133,6 +175,10 @@ pub(super) fn render_output_field_type(
133175

134176
if repeated {
135177
f.write_str("!]")?;
178+
} else if !optional && matches!(ty, FieldType::Scalar(_) | FieldType::Enum(_)) {
179+
// Only scalar and enum types can be non-null based on optional flag
180+
// Message types are always nullable
181+
f.write_str("!")?;
136182
}
137183

138184
Ok(())
@@ -235,3 +281,104 @@ fn render_message_type_name(
235281
}
236282
}
237283
}
284+
285+
fn render_entity_types(
286+
schema: &GrpcSchema,
287+
messages_to_render_as_output: &BTreeSet<ProtoMessageId>,
288+
f: &mut fmt::Formatter<'_>,
289+
) -> fmt::Result {
290+
use std::collections::{HashMap, HashSet};
291+
292+
// entity name -> (list of key fields, message ID for field type lookup)
293+
let mut entities: HashMap<String, (Vec<&str>, ProtoMessageId)> = HashMap::new();
294+
295+
let mut rendered_output_types: HashSet<String> = HashSet::new();
296+
for message_id in messages_to_render_as_output {
297+
let message = &schema[*message_id];
298+
rendered_output_types.insert(message.graphql_output_name().to_string());
299+
}
300+
301+
for message_id in messages_to_render_as_output {
302+
let message = &schema[*message_id];
303+
for entity_info in &message.derives {
304+
if rendered_output_types.contains(&entity_info.entity) {
305+
continue;
306+
}
307+
308+
match &entity_info.is {
309+
Some(simple_is) => {
310+
let key_fields: Vec<&str> = simple_is.fields.iter().map(|f| f.output_field_name.as_str()).collect();
311+
312+
entities.insert(entity_info.entity.clone(), (key_fields, *message_id));
313+
}
314+
None => {
315+
entities.insert(entity_info.entity.clone(), (vec!["id"], *message_id));
316+
}
317+
}
318+
}
319+
}
320+
321+
let mut sorted_entities: Vec<_> = entities.into_iter().collect();
322+
sorted_entities.sort_by_key(|(entity_name, _)| entity_name.clone());
323+
324+
for (entity_name, (key_fields, message_id)) in sorted_entities {
325+
f.write_str("\n")?;
326+
327+
// Format the @key directive
328+
if key_fields.len() == 1 {
329+
write!(f, "type {} @key(fields: \"{}\")", entity_name, key_fields[0])?;
330+
} else {
331+
// For composite keys, use space-separated format
332+
write!(f, "type {} @key(fields: \"", entity_name)?;
333+
for (i, field) in key_fields.iter().enumerate() {
334+
if i > 0 {
335+
f.write_str(" ")?;
336+
}
337+
f.write_str(field)?;
338+
}
339+
f.write_str("\")")?;
340+
}
341+
342+
f.write_str(" {\n")?;
343+
344+
let message = &schema[message_id];
345+
let matching_entity_info = message.derives.iter().find(|info| info.entity == entity_name);
346+
347+
if let Some(entity_info) = matching_entity_info {
348+
if let Some(simple_is) = &entity_info.is {
349+
// Render each key field with its corresponding type from the message
350+
for is_field in &simple_is.fields {
351+
f.write_str(INDENT)?;
352+
f.write_str(&is_field.output_field_name)?;
353+
f.write_str(": ")?;
354+
355+
// Find the corresponding field in the message
356+
let field_found = message_id.fields(schema).find(|f| f.name == is_field.input_field_name);
357+
358+
if let Some(field) = field_found {
359+
render_output_field_type(schema, &field.r#type, false, true, f)?;
360+
} else {
361+
// Default to String if field not found
362+
f.write_str("String")?;
363+
}
364+
f.write_str("\n")?;
365+
}
366+
} else {
367+
// No is mapping, default to id: String
368+
f.write_str(INDENT)?;
369+
f.write_str("id: String\n")?;
370+
}
371+
} else {
372+
// No entity info, render all key fields as String
373+
for field in &key_fields {
374+
f.write_str(INDENT)?;
375+
f.write_str(field)?;
376+
f.write_str(": String\n")?;
377+
}
378+
}
379+
380+
f.write_str("}\n")?;
381+
}
382+
383+
Ok(())
384+
}

0 commit comments

Comments
 (0)