diff --git a/src/HotChocolate/Fusion/src/Abstractions/FusionGraphPackage.cs b/src/HotChocolate/Fusion/src/Abstractions/FusionGraphPackage.cs index 661185f7313..7f4207a70b4 100644 --- a/src/HotChocolate/Fusion/src/Abstractions/FusionGraphPackage.cs +++ b/src/HotChocolate/Fusion/src/Abstractions/FusionGraphPackage.cs @@ -416,7 +416,7 @@ public async Task> GetSubgraphConfiguration /// /// The Fusion graph package must be opened in read/write mode to update contents. /// - public Task SetSubgraphConfigurationAsync( + public async Task SetSubgraphConfigurationAsync( SubgraphConfiguration configuration, CancellationToken cancellationToken = default) { @@ -430,9 +430,46 @@ public Task SetSubgraphConfigurationAsync( throw new FusionGraphPackageException(FusionGraphPackage_CannotWrite); } - if (_package.RelationshipExists(configuration.Name)) + await RemoveSubgraphConfigurationAsync(configuration.Name, cancellationToken); + + await WriteSubgraphConfigurationAsync(configuration, cancellationToken); + } + + /// + /// Removes a subgraph configuration from the package. + /// + /// + /// The name of the subgraph configuration to remove. + /// + /// + /// The cancellation token. + /// + /// + /// A representing the asynchronous operation. + /// + /// + /// is null. + /// + /// + /// The Fusion graph package must be opened in read/write mode to update contents. + /// + public Task RemoveSubgraphConfigurationAsync( + string subgraphName, + CancellationToken cancellationToken = default) + { + if (subgraphName is null) { - var rootRel = _package.GetRelationship(configuration.Name); + throw new ArgumentNullException(nameof(subgraphName)); + } + + if (_package.FileOpenAccess != FileAccess.ReadWrite) + { + throw new FusionGraphPackageException(FusionGraphPackage_CannotWrite); + } + + if (_package.RelationshipExists(subgraphName)) + { + var rootRel = _package.GetRelationship(subgraphName); var rootPart = _package.GetPart(rootRel.TargetUri); foreach (var relationship in rootPart.GetRelationships()) @@ -440,11 +477,13 @@ public Task SetSubgraphConfigurationAsync( _package.DeletePart(relationship.TargetUri); } - _package.DeleteRelationship(configuration.Name); + _package.DeleteRelationship(subgraphName); _package.DeletePart(rootPart.Uri); } - return WriteSubgraphConfigurationAsync(configuration, cancellationToken); + _package.Flush(); + + return Task.CompletedTask; } private static async Task ReadSchemaPartAsync( diff --git a/src/HotChocolate/Fusion/src/Abstractions/SubgraphConfigJsonSerializer.cs b/src/HotChocolate/Fusion/src/Abstractions/SubgraphConfigJsonSerializer.cs index 6937fe33213..c500e868009 100644 --- a/src/HotChocolate/Fusion/src/Abstractions/SubgraphConfigJsonSerializer.cs +++ b/src/HotChocolate/Fusion/src/Abstractions/SubgraphConfigJsonSerializer.cs @@ -203,7 +203,7 @@ public static async Task ParseAsync( case "extensions": extensions = property.Value.SafeClone(); break; - + default: throw new NotSupportedException( $"Configuration property `{property.Value}` is not supported."); diff --git a/src/HotChocolate/Fusion/src/CommandLine/Commands/ComposeCommand.cs b/src/HotChocolate/Fusion/src/CommandLine/Commands/ComposeCommand.cs index e6a49f98a51..08711f5547f 100644 --- a/src/HotChocolate/Fusion/src/CommandLine/Commands/ComposeCommand.cs +++ b/src/HotChocolate/Fusion/src/CommandLine/Commands/ComposeCommand.cs @@ -34,6 +34,9 @@ public ComposeCommand() : base("compose") fusionPackageSettingsFile.AddAlias("--package-settings"); fusionPackageSettingsFile.AddAlias("--settings"); + var removeSubgraphs = new Option?>("--remove"); + removeSubgraphs.AddAlias("-r"); + var workingDirectory = new WorkingDirectoryOption(); var enableNodes = new Option("--enable-nodes"); @@ -43,12 +46,14 @@ public ComposeCommand() : base("compose") AddOption(subgraphPackageFile); AddOption(workingDirectory); AddOption(enableNodes); + AddOption(removeSubgraphs); this.SetHandler( ExecuteAsync, Bind.FromServiceProvider(), fusionPackageFile, subgraphPackageFile, + removeSubgraphs, fusionPackageSettingsFile, workingDirectory, enableNodes, @@ -61,6 +66,7 @@ private static async Task ExecuteAsync( IConsole console, FileInfo packageFile, List? subgraphPackageFiles, + List? removeSubgraphs, FileInfo? settingsFile, DirectoryInfo workingDirectory, bool? enableNodes, @@ -94,8 +100,21 @@ private static async Task ExecuteAsync( await using var package = FusionGraphPackage.Open(packageFile.FullName); + if(removeSubgraphs is not null) + { + foreach (var subgraph in removeSubgraphs) + { + await package.RemoveSubgraphConfigurationAsync(subgraph, cancellationToken); + } + } + var configs = (await package.GetSubgraphConfigurationsAsync(cancellationToken)).ToDictionary(t => t.Name); - await ResolveSubgraphPackagesAsync(workingDirectory, subgraphPackageFiles, configs, cancellationToken); + + // resolve subraph packages will scan the directory for fsp's. In case of remove we don't want to do that. + if (removeSubgraphs is not { Count: > 0 } || subgraphPackageFiles is { Count: > 0 }) + { + await ResolveSubgraphPackagesAsync(workingDirectory, subgraphPackageFiles, configs, cancellationToken); + } using var settingsJson = settingsFile.Exists ? JsonDocument.Parse(await File.ReadAllTextAsync(settingsFile.FullName, cancellationToken)) diff --git a/src/HotChocolate/Fusion/test/CommandLine.Tests/ComposeCommandTests.cs b/src/HotChocolate/Fusion/test/CommandLine.Tests/ComposeCommandTests.cs index 9fdc6a1c41e..613901c50d0 100644 --- a/src/HotChocolate/Fusion/test/CommandLine.Tests/ComposeCommandTests.cs +++ b/src/HotChocolate/Fusion/test/CommandLine.Tests/ComposeCommandTests.cs @@ -265,4 +265,67 @@ public async Task Compose_Loose_Subgraph_Files() snapshot.MatchSnapshot(); } + + [Fact] + public async Task Compose_Fusion_Graph_Remove_Subgraph() + { + // arrange + using var demoProject = await DemoProject.CreateAsync(); + var accountConfig = demoProject.Accounts.ToConfiguration(AccountsExtensionSdl); + var account = CreateFiles(accountConfig); + var accountSubgraphPackageFile = CreateTempFile(); + + await PackageHelper.CreateSubgraphPackageAsync( + accountSubgraphPackageFile, + new SubgraphFiles( + account.SchemaFile, + account.TransportConfigFile, + account.ExtensionFiles)); + + var reviewConfig = demoProject.Reviews2.ToConfiguration(Reviews2ExtensionSdl); + var review = CreateFiles(reviewConfig); + var reviewSubgraphPackageFile = CreateTempFile(); + + await PackageHelper.CreateSubgraphPackageAsync( + reviewSubgraphPackageFile, + new SubgraphFiles( + review.SchemaFile, + review.TransportConfigFile, + review.ExtensionFiles)); + + var packageFile = CreateTempFile(Extensions.FusionPackage); + + var app = App.CreateBuilder().Build(); + await app.InvokeAsync(new[] { "compose", "-p", packageFile, "-s", accountSubgraphPackageFile }); + + app = App.CreateBuilder().Build(); + await app.InvokeAsync( + new[] { "compose", "-p", packageFile, "-s", reviewSubgraphPackageFile }); + + // act + app = App.CreateBuilder().Build(); + await app.InvokeAsync( + new[] { "compose", "-p", packageFile, "-r", "Reviews2" }); + + // assert + Assert.True(File.Exists(packageFile)); + + await using var package = FusionGraphPackage.Open(packageFile, FileAccess.Read); + + var fusionGraph = await package.GetFusionGraphAsync(); + var schema = await package.GetSchemaAsync(); + var subgraphs = await package.GetSubgraphConfigurationsAsync(); + + var snapshot = new Snapshot(); + + snapshot.Add(schema, "Schema Document"); + snapshot.Add(fusionGraph, "Fusion Graph Document"); + + foreach (var subgraph in subgraphs) + { + snapshot.Add(subgraph, $"{subgraph.Name} Subgraph Configuration"); + } + + snapshot.MatchSnapshot(); + } } diff --git a/src/HotChocolate/Fusion/test/CommandLine.Tests/__snapshots__/ComposeCommandTests.Compose_Fusion_Graph_Remove_Subgraph.snap b/src/HotChocolate/Fusion/test/CommandLine.Tests/__snapshots__/ComposeCommandTests.Compose_Fusion_Graph_Remove_Subgraph.snap new file mode 100644 index 00000000000..5ae2aa95ee3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/CommandLine.Tests/__snapshots__/ComposeCommandTests.Compose_Fusion_Graph_Remove_Subgraph.snap @@ -0,0 +1,127 @@ +Schema Document +--------------- +schema { + query: Query + mutation: Mutation +} + +type Query { + userById(id: ID!): User + users: [User!]! + usersById(ids: [ID!]!): [User!]! + viewer: Viewer! +} + +type Mutation { + addUser(input: AddUserInput!): AddUserPayload! +} + +type AddUserPayload { + user: User +} + +type SomeData { + accountValue: String! +} + +type User implements Node { + birthdate: Date! + id: ID! + name: String! + username: String! +} + +type Viewer { + data: SomeData! + user: User +} + +"The node interface is implemented by entities that have a global unique identifier." +interface Node { + id: ID! +} + +input AddUserInput { + birthdate: Date! + name: String! + username: String! +} + +"The `Date` scalar represents an ISO-8601 compliant date type." +scalar Date +--------------- + +Fusion Graph Document +--------------- +schema @fusion(version: 1) @transport(subgraph: "Accounts", group: "Fusion", location: "http:\/\/localhost:5000\/graphql", kind: "HTTP") @transport(subgraph: "Accounts", group: "Fusion", location: "ws:\/\/localhost:5000\/graphql", kind: "WebSocket") { + query: Query + mutation: Mutation +} + +type Query { + userById(id: ID!): User @variable(subgraph: "Accounts", name: "id", argument: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $id) }", arguments: [ { name: "id", type: "ID!" } ]) + users: [User!]! @resolver(subgraph: "Accounts", select: "{ users }") + usersById(ids: [ID!]!): [User!]! @variable(subgraph: "Accounts", name: "ids", argument: "ids") @resolver(subgraph: "Accounts", select: "{ usersById(ids: $ids) }", arguments: [ { name: "ids", type: "[ID!]!" } ]) + viewer: Viewer! @resolver(subgraph: "Accounts", select: "{ viewer }") +} + +type Mutation { + addUser(input: AddUserInput!): AddUserPayload! @variable(subgraph: "Accounts", name: "input", argument: "input") @resolver(subgraph: "Accounts", select: "{ addUser(input: $input) }", arguments: [ { name: "input", type: "AddUserInput!" } ]) +} + +type AddUserPayload { + user: User @source(subgraph: "Accounts") +} + +type SomeData { + accountValue: String! @source(subgraph: "Accounts") +} + +type User implements Node @variable(subgraph: "Accounts", name: "User_id", select: "id") @resolver(subgraph: "Accounts", select: "{ userById(id: $User_id) }", arguments: [ { name: "User_id", type: "ID!" } ]) @resolver(subgraph: "Accounts", select: "{ usersById(ids: $User_id) }", arguments: [ { name: "User_id", type: "[ID!]!" } ], kind: "BATCH") { + birthdate: Date! @source(subgraph: "Accounts") + id: ID! @source(subgraph: "Accounts") + name: String! @source(subgraph: "Accounts") + username: String! @source(subgraph: "Accounts") +} + +type Viewer { + data: SomeData! @source(subgraph: "Accounts") + user: User @source(subgraph: "Accounts") +} + +"The node interface is implemented by entities that have a global unique identifier." +interface Node { + id: ID! +} + +input AddUserInput { + birthdate: Date! + name: String! + username: String! +} + +"The `Date` scalar represents an ISO-8601 compliant date type." +scalar Date +--------------- + +Accounts Subgraph Configuration +--------------- +{ + "Name": "Accounts", + "Schema": "schema {\n query: Query\n mutation: Mutation\n}\n\n\"The node interface is implemented by entities that have a global unique identifier.\"\ninterface Node {\n id: ID!\n}\n\ntype Query {\n \"Fetches an object given its ID.\"\n node(\"ID of the object.\" id: ID!): Node\n \"Lookup nodes by a list of IDs.\"\n nodes(\"The list of node IDs.\" ids: [ID!]!): [Node]!\n users: [User!]!\n userById(id: ID!): User\n usersById(ids: [ID!]!): [User!]!\n viewer: Viewer!\n}\n\ntype Mutation {\n addUser(input: AddUserInput!): AddUserPayload!\n}\n\n\"The `Date` scalar represents an ISO-8601 compliant date type.\"\nscalar Date\n\ntype User implements Node {\n id: ID!\n name: String!\n birthdate: Date!\n username: String!\n}\n\ntype Viewer {\n user: User\n data: SomeData!\n}\n\ntype SomeData {\n accountValue: String!\n}\n\ninput AddUserInput {\n name: String!\n username: String!\n birthdate: Date!\n}\n\ntype AddUserPayload {\n user: User\n}", + "Extensions": [ + "extend type Query {\n userById(id: ID!\n @is(field: \"id\")): User!\n usersById(ids: [ID!]!\n @is(field: \"id\")): [User!]!\n}" + ], + "Clients": [ + { + "ClientName": null, + "BaseAddress": "http://localhost:5000/graphql" + }, + { + "ClientName": null, + "BaseAddress": "ws://localhost:5000/graphql" + } + ], + "ConfigurationExtensions": null +} +---------------