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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

### 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))

### Improvements

## 1.1.291 — 2026-10-26
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,46 @@ namespace CosmosShell.Tests.CommandTests;
using System.Text.Json;
using Azure.Data.Cosmos.Shell.Commands;
using Azure.Data.Cosmos.Shell.Core;
using Newtonsoft.Json.Linq;

public class DataPlaneCosmosResourceOperationsTests
{
[Fact]
public void IndexingPolicy_ServiceReturnedIndexes_RoundTripPreservesMetadata()
{
const string json = """
{
"indexingMode": "consistent",
"automatic": true,
"includedPaths": [
{"path": "/addressId/?", "isFullIndex": true, "indexes": [
{"kind": "Range", "dataType": "Number", "precision": -1},
{"kind": "Range", "dataType": "String", "precision": -1}
]},
{"path": "/docType/?", "indexes": []},
{"path": "/rowguid/?", "indexes": []}
],
"excludedPaths": [{"path": "/\"_etag\"/?"}, {"path": "/*"}],
"compositeIndexes": [[{"path": "/docType", "order": "ascending"}, {"path": "/rowguid", "order": "descending"}]],
"spatialIndexes": [{"path": "/location/*", "types": ["Point"]}],
"vectorIndexes": [{"path": "/embedding", "type": "quantizedFlat"}],
"fullTextIndexes": [{"path": "/text"}]
}
""";
var initial = DataPlaneCosmosResourceOperations.ParseIndexingPolicy(json);
var current = DataPlaneCosmosResourceOperations.SerializeIndexingPolicy(initial);
var updated = IndexCommand.AddIncludedPaths(current, ["/city/?"]);
var roundTrip = DataPlaneCosmosResourceOperations.SerializeIndexingPolicy(
DataPlaneCosmosResourceOperations.ParseIndexingPolicy(updated));

var before = JObject.Parse(current);
var after = JObject.Parse(roundTrip);
var included = Assert.IsType<JArray>(after["includedPaths"]);
var added = Assert.Single(included, path => (string?)path["path"] == "/city/?");
added.Remove();
Assert.True(JToken.DeepEquals(before, after), $"Expected: {before}\nActual: {after}");
}

[Theory]
[InlineData(null, null)]
[InlineData("none", null)]
Expand Down
59 changes: 59 additions & 0 deletions CosmosDBShell.Tests/Integration/IndexPolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace CosmosShell.Tests.Integration;

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

public class IndexPolicyTests : ConnectedEmulatorTestBase
{
[Fact]
public async Task IndexAdd_SelectivePolicy_PreservesServiceReturnedPolicyAfterReadback()
{
var databaseName = $"IndexAdd_{Guid.NewGuid():N}";
CreatedDatabases.Add(databaseName);
var database = await CosmosClient.CreateDatabaseAsync(databaseName, cancellationToken: TestContext.Current.CancellationToken);
await database.Database.CreateContainerAsync("Addresses", "/addressId", cancellationToken: TestContext.Current.CancellationToken);

const string policy = """
{"indexingMode":"consistent","automatic":true,
"includedPaths":[{"path":"/addressId/?"},{"path":"/docType/?"},{"path":"/rowguid/?"}],
"excludedPaths":[{"path":"/\"_etag\"/?"},{"path":"/*"}]}
""";
var target = $"--database={databaseName} --container=Addresses";
var set = await ExecuteAsync($"index set '{policy}' {target}");
Assert.False(set.IsError, FormatError(set));

var container = CosmosClient.GetContainer(databaseName, "Addresses");
var before = await container.ReadContainerAsync(cancellationToken: TestContext.Current.CancellationToken);
var expected = JObject.Parse(DataPlaneCosmosResourceOperations.SerializeIndexingPolicy(before.Resource.IndexingPolicy));

before.Resource.IndexingPolicy.IncludedPaths.Add(new IncludedPath { Path = "/city/?" });
await container.ReplaceContainerAsync(before.Resource, cancellationToken: TestContext.Current.CancellationToken);
var sdkControl = await container.ReadContainerAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Single(sdkControl.Resource.IndexingPolicy.IncludedPaths, path => path.Path == "/city/?");
var reset = await ExecuteAsync($"index set '{policy}' {target}");
Assert.False(reset.IsError, FormatError(reset));

var add = await ExecuteAsync($"index add /city/? {target}");
Assert.False(add.IsError, $"{FormatError(add)}\nOriginal policy: {expected}");
var repeated = await ExecuteAsync($"index add /city/? {target}");
Assert.False(repeated.IsError, FormatError(repeated));

var after = await container.ReadContainerAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Single(after.Resource.IndexingPolicy.IncludedPaths, path => path.Path == "/city/?");
after.Resource.IndexingPolicy.IncludedPaths.Remove(
after.Resource.IndexingPolicy.IncludedPaths.Single(path => path.Path == "/city/?"));
var actual = JObject.Parse(DataPlaneCosmosResourceOperations.SerializeIndexingPolicy(after.Resource.IndexingPolicy));
Assert.True(JToken.DeepEquals(expected, actual), $"Expected: {expected}\nActual: {actual}");

var remove = await ExecuteAsync($"index remove /city/? {target}");
Assert.False(remove.IsError, FormatError(remove));
var removed = await container.ReadContainerAsync(cancellationToken: TestContext.Current.CancellationToken);
var restored = JObject.Parse(DataPlaneCosmosResourceOperations.SerializeIndexingPolicy(removed.Resource.IndexingPolicy));
Assert.True(JToken.DeepEquals(expected, restored), $"Expected: {expected}\nActual: {restored}");
}
}
2 changes: 2 additions & 0 deletions CosmosDBShell.Tests/Parser/CommandExecutionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ public async Task Exec_ShellWords_PreservePathsAndNegativeArguments()
[InlineData("-1.5 +2", "-1.5 +2")]
[InlineData("\"--format=json\"", "--format=json")]
[InlineData("https://localhost:8081 *.json", "https://localhost:8081 *.json")]
[InlineData("/city/? /probeFlag/?", "/city/? /probeFlag/?")]
[InlineData("https://example.test/items?city=London", "https://example.test/items?city=London")]
public async Task Exec_ShellWords_RunLikeDirectArguments(string arguments, string expected)
{
var state = await Shell.RunCommandAsync(new(), $"exec \"echo\" {arguments}", TestContext.Current.CancellationToken);
Expand Down
40 changes: 40 additions & 0 deletions CosmosDBShell.Tests/Parser/StatementParserStructureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,46 @@ public void DuplicateParameters_HighlightFunctionDefinition(string source)
Assert.Equal(source.Length, error.Length);
}

[Fact]
public void IndexPaths_PreserveQuestionMarksAndStatementBoundaries()
{
const string input = "index add /city/? /docType/?; index remove /city/?";
var (statements, errors) = ParseWithErrors(input);

Assert.Empty(errors);
Assert.Equal(2, statements.Count);
var add = Assert.IsType<CommandStatement>(statements[0]);
Assert.Equal(new[] { "add", "/city/?", "/docType/?" }, add.Arguments.Select(argument => argument.ToString()));
Assert.Equal(7, add.Arguments[1].Length);
Assert.Equal(input.IndexOf("/city/?", StringComparison.Ordinal), add.Arguments[1].Start);
var remove = Assert.IsType<CommandStatement>(statements[1]);
Assert.Equal(new[] { "remove", "/city/?" }, remove.Arguments.Select(argument => argument.ToString()));
}

[Theory]
[InlineData("index add /city/?")]
[InlineData("exec \"index\" add /city/?")]
[InlineData("$policy = (index add /city/?)")]
[InlineData("index add --paths=/city/?")]
public void IndexPaths_PreserveQuestionMarksAcrossCommandForms(string input)
{
var (statements, errors) = ParseWithErrors(input);

Assert.Empty(errors);
var statement = Assert.Single(statements);
var arguments = statement switch
{
CommandStatement command => command.Arguments,
ExecStatement executed => executed.Arguments,
AssignmentStatement assignment => Assert.IsType<CommandExpression>(
Assert.IsType<ParensExpression>(assignment.Value).InnerExpression).Arguments,
_ => throw new InvalidOperationException("Unexpected statement type."),
};
var path = arguments[^1] is CommandOption option ? option.Value : arguments[^1];
Assert.NotNull(path);
Assert.Equal("/city/?", path.ToString());
}

[Fact]
public void AppendOutputRedirect_IsRecognized()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ private static bool CanContinueShellWord(TokenType type)
case TokenType.Or:
case TokenType.Xor:
case TokenType.Comma:
case TokenType.Question:
return true;
default:
return false;
Expand Down
3 changes: 3 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,9 @@ Options:
|`set`|Updates the indexing policy. Pass `--mode` (`consistent` or `none`, case-insensitive) and/or `--automatic` to patch the current policy, or provide a full indexing policy JSON document to replace it.|

Paths use the Cosmos DB indexing path syntax, for example `/address/*` or `/name/?`.
These paths can be passed unquoted; the trailing `?` is preserved. Successful policy
readback confirms the configured metadata, not whether an emulator implements the
corresponding index-performance behavior.

#### Examples

Expand Down
Loading