From 99b99955000f8e88259e5642819ed5cfa85efbac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Wed, 28 Oct 2026 10:10:20 +0100 Subject: [PATCH] Preserve '?' in unquoted command arguments (#260) Unquoted shell words stopped at '?', so 'index add /city/?' sent '/city/' to Cosmos DB and the service rejected the indexing policy with HTTP 400. Treat '?' as part of a shell word so scalar indexing paths and wildcard patterns reach commands unchanged. Fixes #260 --- CHANGELOG.md | 2 + .../DataPlaneCosmosResourceOperationsTests.cs | 37 ++++++++++++ .../Integration/IndexPolicyTests.cs | 59 +++++++++++++++++++ .../Parser/CommandExecutionTests.cs | 2 + .../Parser/StatementParserStructureTests.cs | 40 +++++++++++++ .../CommandShellWordParser.cs | 1 + docs/commands.md | 3 + 7 files changed, 144 insertions(+) create mode 100644 CosmosDBShell.Tests/Integration/IndexPolicyTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b9156fb..4d41185c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CosmosDBShell.Tests/CommandTests/DataPlaneCosmosResourceOperationsTests.cs b/CosmosDBShell.Tests/CommandTests/DataPlaneCosmosResourceOperationsTests.cs index 2406b27e..73b12884 100644 --- a/CosmosDBShell.Tests/CommandTests/DataPlaneCosmosResourceOperationsTests.cs +++ b/CosmosDBShell.Tests/CommandTests/DataPlaneCosmosResourceOperationsTests.cs @@ -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(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)] diff --git a/CosmosDBShell.Tests/Integration/IndexPolicyTests.cs b/CosmosDBShell.Tests/Integration/IndexPolicyTests.cs new file mode 100644 index 00000000..1dd21601 --- /dev/null +++ b/CosmosDBShell.Tests/Integration/IndexPolicyTests.cs @@ -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}"); + } +} diff --git a/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs b/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs index e0bf4d14..7a19c36a 100644 --- a/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs +++ b/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs @@ -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); diff --git a/CosmosDBShell.Tests/Parser/StatementParserStructureTests.cs b/CosmosDBShell.Tests/Parser/StatementParserStructureTests.cs index 87596045..41c4dda1 100644 --- a/CosmosDBShell.Tests/Parser/StatementParserStructureTests.cs +++ b/CosmosDBShell.Tests/Parser/StatementParserStructureTests.cs @@ -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(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(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( + Assert.IsType(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() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/CommandShellWordParser.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/CommandShellWordParser.cs index 2724285f..942890b4 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/CommandShellWordParser.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/CommandShellWordParser.cs @@ -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; diff --git a/docs/commands.md b/docs/commands.md index 39037045..32a35975 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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