Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@

### Breaking changes

- On Entra ID (Azure Resource Manager) connections, `mkdb`, `mkcon`, `create database`, and `create container` now return `created: null` with `outcome: "createdOrUpdated"` instead of `created: true`, because the ARM create-or-update API cannot distinguish a new resource from an existing one. Scripts that treat `created` as a boolean should check `outcome` instead. ([#261](https://github.com/Azure/CosmosDBShell/issues/261))

### New features

### Fixes

- Preserve `?` in unquoted command arguments so scalar indexing paths such as `index add /city/?` and `index remove /city/?` reach Cosmos DB unchanged. ([#260](https://github.com/Azure/CosmosDBShell/issues/260))
- Database and container creation on key, emulator, and static-token connections now report `created: false` and `outcome: "existing"` when an existing resource is reused, instead of always reporting `created: true`. Human-readable output says "Using existing database/container" in that case. ([#261](https://github.com/Azure/CosmosDBShell/issues/261))

### Improvements

Expand Down
208 changes: 208 additions & 0 deletions CosmosDBShell.Tests/CommandTests/ResourceCreationResultTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace CosmosShell.Tests.CommandTests;

using System.Net;
using System.Text.Json;
using Azure;
using Azure.Core;
using Azure.Data.Cosmos.Shell.Commands;
using Azure.Data.Cosmos.Shell.Core;
using Azure.Data.Cosmos.Shell.Parser;
using Azure.Data.Cosmos.Shell.States;
using Azure.Data.Cosmos.Shell.Util;
using Azure.ResourceManager;
using Azure.ResourceManager.CosmosDB;
using Azure.ResourceManager.CosmosDB.Models;
using Microsoft.Azure.Cosmos;
using NSubstitute;
using Spectre.Console;

[Collection(ConsoleOutputTestCollection.Name)]
public class ResourceCreationResultTests
{
public static IEnumerable<object?[]> CreationCases()
{
foreach (var container in new[] { false, true })
{
foreach (var longForm in new[] { false, true })
{
foreach (bool? created in new bool?[] { true, false, null })
{
foreach (var format in new[] { "user", "json" })
{
yield return [container, longForm, created, format];
}
}
}
}
}

[Theory]
[MemberData(nameof(CreationCases))]
public async Task Commands_ReportBackendOutcomeInStructuredAndUserOutput(bool container, bool longForm, bool? created, string format)
{
using var shell = ShellInterpreter.CreateInstance();
var client = Substitute.For<CosmosClient>();
shell.Options = new Program.CosmosShellOptions { Output = format };
if (created.HasValue)
{
ConfigureDataPlane(client, created.Value);
shell.State = new ConnectedState(client);
}
else
{
shell.State = new ConnectedState(client, ConfigureArm());
}

CosmosCommand command = (container, longForm) switch
{
(false, false) => new MakeDbCommand { Name = "db" },
(false, true) => new CreateCommand { Item = "database", Name = "db" },
(true, false) => new MakeContainerCommand { Name = "Items", PartitionKey = "/pk", Database = "db" },
(true, true) => new CreateCommand { Item = "container", Name = "Items", PartitionKey = "/pk", Database = "db" },
};
var state = await command.ExecuteAsync(shell, new CommandState(), string.Empty, TestContext.Current.CancellationToken);
var result = Assert.IsType<ShellJson>(state.Result).Value;
var outcome = !created.HasValue ? "createdOrUpdated" : created.Value ? "created" : "existing";
Assert.Equal(container ? "container" : "database", result.GetProperty("type").GetString());
Assert.Equal(container ? "Items" : "db", result.GetProperty("id").GetString());
Assert.Equal(outcome, result.GetProperty("outcome").GetString());
if (created.HasValue)
{
Assert.Equal(created.Value, result.GetProperty("created").GetBoolean());
}
else
{
Assert.Equal(JsonValueKind.Null, result.GetProperty("created").ValueKind);
await client.DidNotReceiveWithAnyArgs().CreateDatabaseIfNotExistsAsync(default!, default(ThroughputProperties), default, TestContext.Current.CancellationToken);
}

var originalOut = Console.Out;
var originalConsole = AnsiConsole.Console;
using var output = new StringWriter();
try
{
Console.SetOut(output);
AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.No,
ColorSystem = ColorSystemSupport.NoColors,
Out = new AnsiConsoleOutput(output),
});
shell.PrintState(state);
if (format == "json")
{
using var document = JsonDocument.Parse(output.ToString());
Assert.True(JsonElement.DeepEquals(result, document.RootElement));
}
else
{
var key = (container, created) switch
{
(false, true) => "command-mkdb-database_created",
(false, false) => "command-mkdb-database_existing",
(false, null) => "command-mkdb-database_created_or_updated",
(true, true) => "command-mkcon-CreatedContainer",
(true, false) => "command-mkcon-container_existing",
(true, null) => "command-mkcon-container_created_or_updated",
};
var expected = MessageService.GetArgsString(key, container ? "container" : "db", container ? "Items" : "db");
Assert.Equal(expected + Environment.NewLine, output.ToString());
}
}
finally
{
Console.SetOut(originalOut);
AnsiConsole.Console = originalConsole;
}
}

[Theory]
[InlineData(false, 200)]
[InlineData(false, 202)]
[InlineData(true, 200)]
[InlineData(true, 202)]
public async Task ArmCreation_DoesNotInferCreationFromResponseOrExistence(bool container, int status)
{
var context = ConfigureArm(status);
var operations = new ArmCosmosResourceOperations(context);
for (var attempt = 0; attempt < 2; attempt++)
{
var result = container
? await operations.CreateContainerAsync("db", "Items", ["/pk"], null, null, null, null, TestContext.Current.CancellationToken)
: await operations.CreateDatabaseAsync("db", null, null, TestContext.Current.CancellationToken);
Assert.Equal(container ? "Items" : "db", result.Id);
Assert.Equal(ResourceCreationOutcome.CreatedOrUpdated, result.Outcome);
Assert.Null(result.Created);
Assert.Equal("createdOrUpdated", result.OutcomeName);
}

var databases = context.Account.GetCosmosDBSqlDatabases();
if (container)
{
var database = await databases.GetIfExistsAsync("db", TestContext.Current.CancellationToken);
await database.Value!.GetCosmosDBSqlContainers().DidNotReceiveWithAnyArgs()
.GetIfExistsAsync(default!, TestContext.Current.CancellationToken);
}
else
{
await databases.DidNotReceiveWithAnyArgs().GetIfExistsAsync(default!, TestContext.Current.CancellationToken);
}
}

private static void ConfigureDataPlane(CosmosClient client, bool created)
{
var database = Substitute.For<Database>();
database.Id.Returns("db");
client.GetDatabase("db").Returns(database);
var existing = Substitute.For<DatabaseResponse>();
existing.StatusCode.Returns(HttpStatusCode.OK);
database.ReadAsync(Arg.Any<RequestOptions>(), Arg.Any<CancellationToken>()).Returns(existing);
var databaseResponse = Substitute.For<DatabaseResponse>();
databaseResponse.Database.Returns(database);
databaseResponse.StatusCode.Returns(created ? HttpStatusCode.Created : HttpStatusCode.OK);
client.CreateDatabaseIfNotExistsAsync("db", Arg.Any<ThroughputProperties>(), Arg.Any<RequestOptions>(), Arg.Any<CancellationToken>())
.Returns(databaseResponse);
var containerResponse = Substitute.For<ContainerResponse>();
containerResponse.Container.Id.Returns("Items");
containerResponse.StatusCode.Returns(created ? HttpStatusCode.Created : HttpStatusCode.OK);
database.CreateContainerIfNotExistsAsync(Arg.Any<ContainerProperties>(), Arg.Any<ThroughputProperties>(), Arg.Any<RequestOptions>(), Arg.Any<CancellationToken>())
.Returns(containerResponse);
}

private static ArmCosmosContext ConfigureArm(int status = 200)
{
var account = Substitute.For<CosmosDBAccountResource>();
account.Data.Returns(System.ClientModel.Primitives.ModelReaderWriter.Read<CosmosDBAccountData>(
BinaryData.FromString("""{"location":"westus"}"""))!);
var databases = Substitute.For<CosmosDBSqlDatabaseCollection>();
account.GetCosmosDBSqlDatabases().Returns(databases);
var database = Substitute.For<CosmosDBSqlDatabaseResource>();
database.Data.Returns(System.ClientModel.Primitives.ModelReaderWriter.Read<CosmosDBSqlDatabaseData>(
BinaryData.FromString("""{"properties":{"resource":{"id":"db"}}}"""))!);
var databaseOperation = Substitute.For<ArmOperation<CosmosDBSqlDatabaseResource>>();
databaseOperation.Value.Returns(database);
var response = Substitute.For<Response>();
response.Status.Returns(status);
databaseOperation.GetRawResponse().Returns(response);
databases.CreateOrUpdateAsync(WaitUntil.Completed, "db", Arg.Any<CosmosDBSqlDatabaseCreateOrUpdateContent>(), Arg.Any<CancellationToken>())
.Returns(databaseOperation);
databases.GetIfExistsAsync("db", Arg.Any<CancellationToken>())
.Returns(Task.FromResult<NullableResponse<CosmosDBSqlDatabaseResource>>(Response.FromValue(database, Substitute.For<Response>())));
var containers = Substitute.For<CosmosDBSqlContainerCollection>();
database.GetCosmosDBSqlContainers().Returns(containers);
var container = Substitute.For<CosmosDBSqlContainerResource>();
container.Data.Returns(System.ClientModel.Primitives.ModelReaderWriter.Read<CosmosDBSqlContainerData>(
BinaryData.FromString("""{"properties":{"resource":{"id":"Items"}}}"""))!);
var containerOperation = Substitute.For<ArmOperation<CosmosDBSqlContainerResource>>();
containerOperation.Value.Returns(container);
containerOperation.GetRawResponse().Returns(response);
containers.CreateOrUpdateAsync(WaitUntil.Completed, "Items", Arg.Any<CosmosDBSqlContainerCreateOrUpdateContent>(), Arg.Any<CancellationToken>())
.Returns(containerOperation);
var id = new ResourceIdentifier("/subscriptions/s/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/a");
return new ArmCosmosContext(Substitute.For<ArmClient>(), id, "s", "rg", "a", new Uri("https://a.documents.azure.com"), account);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,8 @@ public async Task ExistingResource_ProvisionedWithExplicitThroughput_PreservesSu

var result = await CreateResourceAsync(operations, isContainer, "manual", 400);

Assert.Equal(isContainer ? "Items" : "db", result);
Assert.Equal(isContainer ? "Items" : "db", result.Id);
Assert.Equal(ResourceCreationOutcome.Existing, result.Outcome);
}

[Theory]
Expand All @@ -362,7 +363,7 @@ public async Task ExistingResource_OfferReadFailure_OnlyMissingOfferPreservesSuc

if (status == HttpStatusCode.NotFound)
{
Assert.Equal(isContainer ? "Items" : "db", await CreateResourceAsync(operations, isContainer, null, 400));
Assert.Equal(isContainer ? "Items" : "db", (await CreateResourceAsync(operations, isContainer, null, 400)).Id);
}
else
{
Expand All @@ -372,7 +373,7 @@ public async Task ExistingResource_OfferReadFailure_OnlyMissingOfferPreservesSuc
}
}

private static Task<string> CreateResourceAsync(DataPlaneCosmosResourceOperations operations, bool isContainer, string? scale, int? ru)
private static Task<ResourceCreationResult> CreateResourceAsync(DataPlaneCosmosResourceOperations operations, bool isContainer, string? scale, int? ru)
=> isContainer
? operations.CreateContainerAsync("db", "Items", ["/pk"], null, null, scale, ru, TestContext.Current.CancellationToken)
: operations.CreateDatabaseAsync("db", scale, ru, TestContext.Current.CancellationToken);
Expand Down
84 changes: 84 additions & 0 deletions CosmosDBShell.Tests/Integration/ResourceCreationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace CosmosShell.Tests.Integration;

using Azure.Data.Cosmos.Shell.Core;
using Azure.Data.Cosmos.Shell.Parser;
using Microsoft.Azure.Cosmos;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

public class ResourceCreationTests : ConnectedEmulatorTestBase
{
[Theory]
[InlineData("mkdb")]
[InlineData("create database")]
public async Task DatabaseCreation_ReportsCreatedThenExisting(string command)
{
var name = $"Creation_{Guid.NewGuid():N}";
CreatedDatabases.Add(name);
await AssertCreationAsync($"{command} {name}", "database", name, true);
var before = await CosmosClient.GetDatabase(name).ReadAsync(cancellationToken: TestContext.Current.CancellationToken);
await AssertCreationAsync($"{command} {name}", "database", name, false);
var after = await CosmosClient.GetDatabase(name).ReadAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(JsonConvert.SerializeObject(before.Resource), JsonConvert.SerializeObject(after.Resource));
}

[Theory]
[InlineData("mkcon", false)]
[InlineData("create container", false)]
[InlineData("mkcon", true)]
[InlineData("create container", true)]
public async Task ContainerCreation_PreservesExistingResourceAndData(string command, bool sdkCreated)
{
var name = $"Creation_{Guid.NewGuid():N}";
CreatedDatabases.Add(name);
var database = await CosmosClient.CreateDatabaseAsync(name, cancellationToken: TestContext.Current.CancellationToken);
if (sdkCreated)
{
var properties = new ContainerProperties("Items", "/pk")
{
PartitionKeyDefinitionVersion = PartitionKeyDefinitionVersion.V2,
DefaultTimeToLive = 3600,
};
properties.UniqueKeyPolicy.UniqueKeys.Add(new UniqueKey { Paths = { "/email" } });
await database.Database.CreateContainerAsync(properties, cancellationToken: TestContext.Current.CancellationToken);
}

var text = $"{command} Items /pk --database={name}";
await AssertCreationAsync(text, "container", "Items", !sdkCreated);
var container = CosmosClient.GetContainer(name, "Items");
var before = await container.ReadContainerAsync(cancellationToken: TestContext.Current.CancellationToken);
var item = new { id = "retained", pk = "test", email = "test@example.test" };
await container.CreateItemAsync(item, new PartitionKey("test"), cancellationToken: TestContext.Current.CancellationToken);
await AssertCreationAsync(text, "container", "Items", false);
var after = await container.ReadContainerAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.True(JToken.DeepEquals(JObject.Parse(JsonConvert.SerializeObject(before.Resource)), JObject.Parse(JsonConvert.SerializeObject(after.Resource))));
if (sdkCreated)
{
Assert.Equal(PartitionKeyDefinitionVersion.V2, after.Resource.PartitionKeyDefinitionVersion);
}

var retained = await container.ReadItemAsync<JObject>("retained", new PartitionKey("test"), cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("test@example.test", (string?)retained.Resource["email"]);

var mismatch = await Assert.ThrowsAsync<ArgumentException>(() =>
Shell.RunCommandAsync(new CommandState(), $"{command} Items /different --database={name}", TestContext.Current.CancellationToken));
Assert.Equal("PartitionKey", mismatch.ParamName);
var unchanged = await container.ReadContainerAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(JsonConvert.SerializeObject(after.Resource), JsonConvert.SerializeObject(unchanged.Resource));
}

private async Task AssertCreationAsync(string command, string type, string id, bool created)
{
var state = await Shell.RunCommandAsync(new CommandState(), command, TestContext.Current.CancellationToken);
Assert.False(state.IsError, FormatError(state));
var result = Assert.IsType<ShellJson>(state.Result).Value;
Assert.Equal(type, result.GetProperty("type").GetString());
Assert.Equal(id, result.GetProperty("id").GetString());
Assert.Equal(created, result.GetProperty("created").GetBoolean());
Assert.Equal(created ? "created" : "existing", result.GetProperty("outcome").GetString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Azure.Data.Cosmos.Shell.Commands;

using System.Diagnostics;
using System.Text.Json;
using Azure.Data.Cosmos.Shell.Mcp;
using Azure.Data.Cosmos.Shell.Parser;
Expand Down Expand Up @@ -149,10 +150,10 @@ private async Task<CommandState> CreateContainerInDatabaseAsync(ConnectedState s
await ValidateDatabaseExistsAsync(state, databaseName, "mkcon", token);

var partitionKeyPaths = this.GetPartitionKeyPaths();
string containerName;
ResourceCreationResult creation;
try
{
containerName = await CosmosResourceFacade.CreateContainerAsync(
creation = await CosmosResourceFacade.CreateContainerAsync(
state,
databaseName,
this.Name ?? string.Empty,
Expand All @@ -177,10 +178,18 @@ private async Task<CommandState> CreateContainerInDatabaseAsync(ConnectedState s
commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new
{
type = "container",
id = containerName,
created = true,
id = creation.Id,
created = creation.Created,
outcome = creation.OutcomeName,
}));
commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-mkcon-CreatedContainer", new Dictionary<string, object> { { "container", containerName } }));
var messageKey = creation.Outcome switch
{
ResourceCreationOutcome.Created => "command-mkcon-CreatedContainer",
ResourceCreationOutcome.Existing => "command-mkcon-container_existing",
ResourceCreationOutcome.CreatedOrUpdated => "command-mkcon-container_created_or_updated",
_ => throw new UnreachableException(),
};
commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString(messageKey, new Dictionary<string, object> { { "container", creation.Id } }));
return commandState;
}

Expand Down
Loading
Loading