Skip to content

Commit e0d18fe

Browse files
committed
feat: federation compose directive
1 parent 47032d7 commit e0d18fe

5 files changed

Lines changed: 473 additions & 30 deletions

File tree

docs/federation-compose-directive/ja/designdoc.md

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -332,34 +332,55 @@ sequenceDiagram
332332

333333
### Process
334334

335-
1. **SubGraph V2 拡張**
336-
1.1. `subgraph_v2.go` を修正
335+
**重要:** 以下のプロセスは TDD(テスト駆動開発)を厳守すること。各機能の実装前に必ずテストを書き、Red → Green → Refactor のサイクルを回すこと。
336+
337+
1. **SubGraph V2 拡張 (TDD)**
338+
1.1. **RED: テストを先に書く** - `subgraph_v2_test.go`
339+
- @composeDirective のパース(既存)
340+
- カスタムディレクティブ定義の抽出テスト
341+
- 複数のカスタムディレクティブの処理テスト
342+
- テストを実行して失敗することを確認
343+
1.2. **GREEN: 最小限の実装** - `subgraph_v2.go`
337344
- SubGraphV2 構造に DirectiveDefinitions フィールドを追加
338345
- extractDirectiveDefinitions() 関数を追加
339346
- NewSubGraphV2() で extractDirectiveDefinitions() を呼び出し
340-
1.2. `subgraph_v2_test.go` にテストを追加
341-
- @composeDirective のパース(既存)
342-
- カスタムディレクティブ定義の抽出
343-
- 複数のカスタムディレクティブの処理
344-
345-
2. **SuperGraph V2 拡張**
346-
2.1. `super_graph_v2.go` を修正
347+
- テストを実行して成功することを確認
348+
1.3. **REFACTOR: リファクタリング**
349+
- コードの重複を排除
350+
- 可読性を向上
351+
- テストが引き続き成功することを確認
352+
353+
2. **SuperGraph V2 拡張 (TDD)**
354+
2.1. **RED: テストを先に書く** - `super_graph_v2_test.go`
355+
- カスタムディレクティブ定義のマージテスト
356+
- ディレクティブ定義の一貫性検証(一致する場合)
357+
- ディレクティブ定義の一貫性検証(不一致の場合、エラー)
358+
- 複数サブグラフからの異なるカスタムディレクティブのマージ
359+
- テストを実行して失敗することを確認
360+
2.2. **GREEN: 最小限の実装** - `super_graph_v2.go`
347361
- SuperGraphV2 構造に DirectiveDefinitions フィールドを追加
348362
- mergeDirectiveDefinitions() 関数を追加
349363
- isDirectiveDefinitionEqual() ヘルパー関数を追加
350364
- isTypeEqual() ヘルパー関数を追加
351365
- NewSuperGraphV2() で mergeDirectiveDefinitions() を呼び出し
352-
2.2. schema 定義への @composeDirective 追加(オプション)
353-
- addSchemaDefinition() 関数を追加
354-
2.3. `super_graph_v2_test.go` にテストを追加
355-
- カスタムディレクティブ定義のマージ
356-
- ディレクティブ定義の一貫性検証(一致する場合)
357-
- ディレクティブ定義の一貫性検証(不一致の場合、エラー)
358-
- 複数サブグラフからの異なるカスタムディレクティブのマージ
359-
360-
3. **統合テスト**
361-
3.1. カスタムディレクティブを使用するサンプルスキーマの作成
362-
3.2. スーパーグラフにカスタムディレクティブが含まれることの確認
366+
- テストを実行して成功することを確認
367+
2.3. **REFACTOR: リファクタリング**
368+
- コードの重複を排除
369+
- 可読性を向上
370+
- テストが引き続き成功することを確認
371+
2.4. **[オプション] schema 定義への @composeDirective 追加 (TDD)**
372+
- RED: テストを書く
373+
- GREEN: addSchemaDefinition() 関数を実装
374+
- REFACTOR: 改善
375+
376+
3. **統合テスト (TDD)**
377+
3.1. **RED: 統合テストを先に書く**
378+
- カスタムディレクティブを使用するサンプルスキーマの作成
379+
- スーパーグラフにカスタムディレクティブが含まれることを確認するテスト
380+
- テストを実行して現状を確認
381+
3.2. **GREEN: 必要な調整**
382+
- テストが成功するように実装を調整
383+
3.3. **REFACTOR: 統合レベルのリファクタリング**
363384

364385
4. **ドキュメント**
365386
4.1. @composeDirective の使用方法を README に追加
@@ -369,6 +390,13 @@ sequenceDiagram
369390
5.1. `make test-all` で全ドメインのテストが通ることを確認
370391
5.2. カスタムディレクティブ機能が既存の動作を壊していないことを確認
371392

393+
**TDD チェックリスト:**
394+
- [ ] 各機能について、実装前にテストを書いたか?
395+
- [ ] テストが最初は失敗することを確認したか?(RED)
396+
- [ ] テストが成功する最小限のコードを書いたか?(GREEN)
397+
- [ ] リファクタリング後もテストが成功することを確認したか?(REFACTOR)
398+
- [ ] 全てのテストが通ることを確認したか?
399+
372400
### Expected Outcomes
373401

374402
- `@composeDirective` で指定されたカスタムディレクティブがスーパーグラフに含まれる

federation/graph/subgraph_v2.go

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ type SubGraphV2 struct {
5252
entities map[string]*Entity // Entity map with entity name as key
5353

5454
// Federation v2 directives
55-
ComposeDirectives []string // @composeDirective directives
55+
ComposeDirectives []string // @composeDirective directives
56+
DirectiveDefinitions map[string]*ast.DirectiveDefinition // Custom directive definitions to compose
5657
}
5758

5859
// NewSubGraphV2 initializes a SubGraphV2 by parsing the schema and extracting entities.
@@ -67,12 +68,14 @@ func NewSubGraphV2(name string, src []byte, host string) (*SubGraphV2, error) {
6768
}
6869

6970
// Initialize SubGraph structure
71+
composeDirectives := extractSchemaComposeDirectives(doc)
7072
sg := &SubGraphV2{
71-
Name: name,
72-
Host: host,
73-
Schema: doc,
74-
entities: make(map[string]*Entity),
75-
ComposeDirectives: extractSchemaComposeDirectives(doc),
73+
Name: name,
74+
Host: host,
75+
Schema: doc,
76+
entities: make(map[string]*Entity),
77+
ComposeDirectives: composeDirectives,
78+
DirectiveDefinitions: extractDirectiveDefinitions(doc, composeDirectives),
7679
}
7780

7881
// Traverse all type definitions
@@ -290,3 +293,33 @@ func extractSchemaComposeDirectives(doc *ast.Document) []string {
290293
func (sg *SubGraphV2) GetComposeDirectives() []string {
291294
return sg.ComposeDirectives
292295
}
296+
297+
// GetDirectiveDefinitions returns the custom directive definitions to compose.
298+
func (sg *SubGraphV2) GetDirectiveDefinitions() map[string]*ast.DirectiveDefinition {
299+
return sg.DirectiveDefinitions
300+
}
301+
302+
// extractDirectiveDefinitions extracts directive definitions that are listed in composeDirectives.
303+
func extractDirectiveDefinitions(
304+
doc *ast.Document,
305+
composeDirectives []string,
306+
) map[string]*ast.DirectiveDefinition {
307+
definitions := make(map[string]*ast.DirectiveDefinition)
308+
309+
// Build a set of directive names to compose (strip leading "@")
310+
composeSet := make(map[string]bool)
311+
for _, name := range composeDirectives {
312+
cleanName := strings.TrimPrefix(name, "@")
313+
composeSet[cleanName] = true
314+
}
315+
316+
for _, def := range doc.Definitions {
317+
if directiveDef, ok := def.(*ast.DirectiveDefinition); ok {
318+
if composeSet[directiveDef.Name.String()] {
319+
definitions[directiveDef.Name.String()] = directiveDef
320+
}
321+
}
322+
}
323+
324+
return definitions
325+
}

federation/graph/subgraph_v2_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,3 +363,126 @@ func TestNewSubGraphV2_WithComposeDirective(t *testing.T) {
363363
t.Errorf("expected compose directive '@custom', got '%s'", composeDirectives[0])
364364
}
365365
}
366+
367+
func TestNewSubGraphV2_ExtractsDirectiveDefinitions(t *testing.T) {
368+
schema := `
369+
schema @composeDirective(name: "@rateLimit") {
370+
query: Query
371+
}
372+
373+
directive @rateLimit(limit: Int!, duration: Int!) on FIELD_DEFINITION
374+
375+
type Query {
376+
expensiveQuery: String! @rateLimit(limit: 10, duration: 60)
377+
}
378+
`
379+
380+
sg, err := graph.NewSubGraphV2("product", []byte(schema), "http://product.example.com")
381+
if err != nil {
382+
t.Fatalf("NewSubGraphV2 failed: %v", err)
383+
}
384+
385+
defs := sg.GetDirectiveDefinitions()
386+
if len(defs) != 1 {
387+
t.Fatalf("expected 1 directive definition, got %d", len(defs))
388+
}
389+
390+
def, ok := defs["rateLimit"]
391+
if !ok {
392+
t.Fatal("expected 'rateLimit' directive definition, not found")
393+
}
394+
395+
if def.Name.String() != "rateLimit" {
396+
t.Errorf("expected directive name 'rateLimit', got '%s'", def.Name.String())
397+
}
398+
}
399+
400+
func TestNewSubGraphV2_ExtractsMultipleDirectiveDefinitions(t *testing.T) {
401+
schema := `
402+
schema
403+
@composeDirective(name: "@rateLimit")
404+
@composeDirective(name: "@cacheControl") {
405+
query: Query
406+
}
407+
408+
directive @rateLimit(limit: Int!) on FIELD_DEFINITION
409+
directive @cacheControl(maxAge: Int!) on FIELD_DEFINITION | OBJECT
410+
411+
type Query {
412+
cachedData: String! @cacheControl(maxAge: 3600) @rateLimit(limit: 100)
413+
}
414+
`
415+
416+
sg, err := graph.NewSubGraphV2("product", []byte(schema), "http://product.example.com")
417+
if err != nil {
418+
t.Fatalf("NewSubGraphV2 failed: %v", err)
419+
}
420+
421+
defs := sg.GetDirectiveDefinitions()
422+
if len(defs) != 2 {
423+
t.Fatalf("expected 2 directive definitions, got %d", len(defs))
424+
}
425+
426+
if _, ok := defs["rateLimit"]; !ok {
427+
t.Error("expected 'rateLimit' directive definition, not found")
428+
}
429+
if _, ok := defs["cacheControl"]; !ok {
430+
t.Error("expected 'cacheControl' directive definition, not found")
431+
}
432+
}
433+
434+
func TestNewSubGraphV2_DirectiveDefinitions_OnlyComposed(t *testing.T) {
435+
// @internal is defined but NOT listed in @composeDirective, so should not be extracted
436+
schema := `
437+
schema @composeDirective(name: "@rateLimit") {
438+
query: Query
439+
}
440+
441+
directive @rateLimit(limit: Int!) on FIELD_DEFINITION
442+
directive @internal on FIELD_DEFINITION
443+
444+
type Query {
445+
data: String! @rateLimit(limit: 10) @internal
446+
}
447+
`
448+
449+
sg, err := graph.NewSubGraphV2("product", []byte(schema), "http://product.example.com")
450+
if err != nil {
451+
t.Fatalf("NewSubGraphV2 failed: %v", err)
452+
}
453+
454+
defs := sg.GetDirectiveDefinitions()
455+
if len(defs) != 1 {
456+
t.Fatalf("expected 1 directive definition (only composed ones), got %d", len(defs))
457+
}
458+
459+
if _, ok := defs["rateLimit"]; !ok {
460+
t.Error("expected 'rateLimit' directive definition, not found")
461+
}
462+
if _, ok := defs["internal"]; ok {
463+
t.Error("'internal' directive should not be extracted (not in @composeDirective)")
464+
}
465+
}
466+
467+
func TestNewSubGraphV2_NoComposeDirective_EmptyDefinitions(t *testing.T) {
468+
schema := `
469+
type Product @key(fields: "id") {
470+
id: ID!
471+
name: String!
472+
}
473+
474+
type Query {
475+
product(id: ID!): Product
476+
}
477+
`
478+
479+
sg, err := graph.NewSubGraphV2("product", []byte(schema), "http://product.example.com")
480+
if err != nil {
481+
t.Fatalf("NewSubGraphV2 failed: %v", err)
482+
}
483+
484+
defs := sg.GetDirectiveDefinitions()
485+
if len(defs) != 0 {
486+
t.Errorf("expected 0 directive definitions, got %d", len(defs))
487+
}
488+
}

federation/graph/super_graph_v2.go

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import (
88

99
// SuperGraphV2 represents an aggregated super graph composed of multiple subgraphs.
1010
type SuperGraphV2 struct {
11-
SubGraphs []*SubGraphV2 // List of subgraphs
12-
Schema *ast.Document // Composed schema
13-
Ownership map[string][]*SubGraphV2 // Field ownership map (e.g., "Product.id" -> [SubGraph])
14-
Graph *WeightedDirectedGraph // Weighted directed graph for Dijkstra-based plan optimization
11+
SubGraphs []*SubGraphV2 // List of subgraphs
12+
Schema *ast.Document // Composed schema
13+
Ownership map[string][]*SubGraphV2 // Field ownership map (e.g., "Product.id" -> [SubGraph])
14+
Graph *WeightedDirectedGraph // Weighted directed graph for Dijkstra-based plan optimization
15+
DirectiveDefinitions map[string]*ast.DirectiveDefinition // Custom directive definitions merged from @composeDirective
1516
}
1617

1718
// NewSuperGraphV2 creates a super graph from a list of SubGraphV2 instances.
@@ -26,6 +27,11 @@ func NewSuperGraphV2(subGraphs []*SubGraphV2) (*SuperGraphV2, error) {
2627
return nil, err
2728
}
2829

30+
// Merge and validate custom directive definitions from @composeDirective
31+
if err := sg.mergeComposeDirectiveDefinitions(); err != nil {
32+
return nil, err
33+
}
34+
2935
// Build ownership map
3036
if err := sg.buildOwnershipMap(); err != nil {
3137
return nil, err
@@ -347,6 +353,74 @@ func (sg *SuperGraphV2) mergeDirectiveDefinition(newDef *ast.DirectiveDefinition
347353
}
348354
}
349355

356+
// mergeComposeDirectiveDefinitions validates and merges custom directive definitions
357+
// listed via @composeDirective across all subgraphs into the super graph.
358+
func (sg *SuperGraphV2) mergeComposeDirectiveDefinitions() error {
359+
sg.DirectiveDefinitions = make(map[string]*ast.DirectiveDefinition)
360+
361+
for _, subGraph := range sg.SubGraphs {
362+
for name, directiveDef := range subGraph.DirectiveDefinitions {
363+
if existing, ok := sg.DirectiveDefinitions[name]; ok {
364+
// Already seen this directive - validate consistency
365+
if !isDirectiveDefinitionEqual(existing, directiveDef) {
366+
return fmt.Errorf(
367+
"inconsistent directive definition for '@%s' between subgraphs",
368+
name,
369+
)
370+
}
371+
// Consistent - skip
372+
continue
373+
}
374+
// New directive definition
375+
sg.DirectiveDefinitions[name] = directiveDef
376+
}
377+
}
378+
379+
return nil
380+
}
381+
382+
// isDirectiveDefinitionEqual checks whether two DirectiveDefinition nodes are equivalent
383+
// by comparing their argument names/types and applicable locations.
384+
func isDirectiveDefinitionEqual(a, b *ast.DirectiveDefinition) bool {
385+
if a.Name.String() != b.Name.String() {
386+
return false
387+
}
388+
389+
if len(a.Arguments) != len(b.Arguments) {
390+
return false
391+
}
392+
393+
for i := range a.Arguments {
394+
if a.Arguments[i].Name.String() != b.Arguments[i].Name.String() {
395+
return false
396+
}
397+
if !isTypeEqual(a.Arguments[i].Type, b.Arguments[i].Type) {
398+
return false
399+
}
400+
}
401+
402+
if len(a.Locations) != len(b.Locations) {
403+
return false
404+
}
405+
406+
locSet := make(map[string]bool)
407+
for _, loc := range a.Locations {
408+
locSet[loc.String()] = true
409+
}
410+
for _, loc := range b.Locations {
411+
if !locSet[loc.String()] {
412+
return false
413+
}
414+
}
415+
416+
return true
417+
}
418+
419+
// isTypeEqual compares two ast.Type values by their string representation.
420+
func isTypeEqual(a, b ast.Type) bool {
421+
return a.String() == b.String()
422+
}
423+
350424
// buildOwnershipMap constructs the ownership map.
351425
// It determines which subgraphs can resolve each field in the composed schema.
352426
func (sg *SuperGraphV2) buildOwnershipMap() error {

0 commit comments

Comments
 (0)