diff --git a/CHANGELOG.md b/CHANGELOG.md index 89fefb6..afae03a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### New features +- **`schema` discovery command and MCP tool.** A new read-only `schema` command infers a container's structure from a small, bounded sample: it returns the partition key path(s), an indexing policy summary, an estimated document count, and inferred field types (with dot notation for nested objects and per-field presence counts). `--sample ` selects how many documents to sample (clamped to 1-100, default 20), `--fields-only` (alias `--short`) returns only the sample count and inferred fields without a metadata read, and `--database`/`--container` override the target. Exposed as a read-only MCP tool so agents can discover container structure cheaply instead of re-sampling or guessing field names. ([#160](https://github.com/Azure/CosmosDBShell/issues/160)) - **`--database` and `--container` startup options.** Navigate to a database or container at startup without composing a `-k "cd ..."` command. Both require `--connect`, and `--container` requires `--database`. Tools that previously built a startup script string to select a location should pass these options instead. See [navigation](docs/navigation.md). ### Breaking changes diff --git a/CosmosDBShell.Tests/CommandTests/SchemaCommandTests.cs b/CosmosDBShell.Tests/CommandTests/SchemaCommandTests.cs new file mode 100644 index 0000000..c13ef1f --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/SchemaCommandTests.cs @@ -0,0 +1,220 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using System.Globalization; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; +using Microsoft.Azure.Cosmos; + +/// +/// Unit tests for . Covers the pure helpers that clamp the +/// sample size and infer field types, which can be exercised without a live Cosmos DB +/// connection. +/// +public class SchemaCommandTests +{ + [Fact] + public void SchemaCommand_IsRegistered() + { + var runner = new CommandRunner(); + + Assert.True(runner.Commands.TryGetValue("schema", out var factory)); + Assert.Equal("schema", factory!.CommandName); + } + + [Fact] + public void NormalizeSample_UsesDefaultWhenMissing() + { + Assert.Equal(SchemaCommand.DefaultSample, SchemaCommand.NormalizeSample(null)); + } + + [Fact] + public void NormalizeSample_ClampsBelowMinimum() + { + Assert.Equal(SchemaCommand.MinSample, SchemaCommand.NormalizeSample(0)); + Assert.Equal(SchemaCommand.MinSample, SchemaCommand.NormalizeSample(-5)); + } + + [Fact] + public void NormalizeSample_ClampsAboveMaximum() + { + Assert.Equal(SchemaCommand.MaxSample, SchemaCommand.NormalizeSample(SchemaCommand.MaxSample + 1)); + Assert.Equal(SchemaCommand.MaxSample, SchemaCommand.NormalizeSample(int.MaxValue)); + } + + [Fact] + public void NormalizeSample_KeepsValueInRange() + { + Assert.Equal(42, SchemaCommand.NormalizeSample(42)); + } + + [Fact] + public void BuildSampleQueryText_UsesServerSideTopLimit() + { + Assert.Equal("SELECT TOP 42 * FROM c", SchemaCommand.BuildSampleQueryText(42)); + } + + [Fact] + public void BuildSampleQueryText_FormatsLimitInvariantly() + { + var previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fa-IR"); + + Assert.Equal("SELECT TOP 42 * FROM c", SchemaCommand.BuildSampleQueryText(42)); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + [Fact] + public void InferSchema_ReportsTopLevelTypes() + { + var documents = Parse( + "{\"id\":\"a\",\"price\":10,\"active\":true}", + "{\"id\":\"b\",\"price\":20,\"active\":false}"); + + var fields = SchemaCommand.InferSchema(documents); + + Assert.Equal(new[] { "active", "id", "price" }, fields.Select(f => f.Path)); + Assert.Equal(new[] { "string" }, Field(fields, "id").Types); + Assert.Equal(new[] { "number" }, Field(fields, "price").Types); + Assert.Equal(new[] { "boolean" }, Field(fields, "active").Types); + } + + [Fact] + public void InferSchema_TracksPresenceAcrossDocuments() + { + var documents = Parse( + "{\"id\":\"a\",\"optional\":1}", + "{\"id\":\"b\"}", + "{\"id\":\"c\"}"); + + var fields = SchemaCommand.InferSchema(documents); + + Assert.Equal(3, Field(fields, "id").Presence); + Assert.Equal(1, Field(fields, "optional").Presence); + } + + [Fact] + public void InferSchema_CountsDuplicatePropertyOncePerDocument() + { + var documents = Parse("{\"value\":1,\"value\":\"text\"}"); + + var field = Field(SchemaCommand.InferSchema(documents), "value"); + + Assert.Equal(1, field.Presence); + Assert.Equal(new[] { "number", "string" }, field.Types); + } + + [Fact] + public void InferSchema_RecordsMultipleTypesForSameField() + { + var documents = Parse( + "{\"value\":1}", + "{\"value\":\"text\"}", + "{\"value\":null}"); + + var types = Field(SchemaCommand.InferSchema(documents), "value").Types; + + Assert.Equal(new[] { "null", "number", "string" }, types); + } + + [Fact] + public void InferSchema_DescribesNestedObjectsWithDotNotation() + { + var documents = Parse("{\"address\":{\"city\":\"Seattle\",\"zip\":\"98101\"}}"); + + var fields = SchemaCommand.InferSchema(documents); + + Assert.Equal(new[] { "object" }, Field(fields, "address").Types); + Assert.Equal(new[] { "string" }, Field(fields, "address.city").Types); + Assert.Equal(new[] { "string" }, Field(fields, "address.zip").Types); + } + + [Fact] + public void InferSchema_HonorsMaxDepth() + { + var documents = Parse("{\"a\":{\"b\":{\"c\":1}}}"); + + var fields = SchemaCommand.InferSchema(documents, maxDepth: 1); + + Assert.Contains(fields, f => f.Path == "a"); + Assert.DoesNotContain(fields, f => f.Path == "a.b"); + } + + [Fact] + public void InferSchema_ReportsArrayType() + { + var documents = Parse("{\"tags\":[1,2,3]}"); + + Assert.Equal(new[] { "array" }, Field(SchemaCommand.InferSchema(documents), "tags").Types); + } + + [Fact] + public void InferSchema_IgnoresNonObjectDocuments() + { + var documents = Parse("42", "\"text\"", "{\"id\":\"a\"}"); + + var fields = SchemaCommand.InferSchema(documents); + + Assert.Single(fields); + Assert.Equal("id", fields[0].Path); + } + + [Fact] + public void BuildResult_ReturnsStructuredSchemaSummary() + { + var properties = new ContainerProperties("Products", "/category"); + var fields = new[] { new SchemaCommand.FieldSchema("id", new[] { "string" }, 2) }; + + var state = SchemaCommand.BuildResult("MyDB", "Products", properties, 12, 20, 2, fields); + var result = Assert.IsType(state.Result).Value; + + Assert.Equal("MyDB", result.GetProperty("database").GetString()); + Assert.Equal("Products", result.GetProperty("container").GetString()); + Assert.Equal("/category", result.GetProperty("partitionKeyPaths")[0].GetString()); + Assert.Equal(12, result.GetProperty("documentCountEstimate").GetInt64()); + Assert.Equal(20, result.GetProperty("sampleSize").GetInt32()); + Assert.Equal(2, result.GetProperty("sampledDocuments").GetInt32()); + Assert.True(result.TryGetProperty("indexingPolicy", out _)); + Assert.Equal("id", result.GetProperty("fields")[0].GetProperty("path").GetString()); + } + + [Fact] + public void BuildFieldsOnlyResult_ReturnsOnlySampleCountAndFields() + { + var fields = new[] { new SchemaCommand.FieldSchema("id", new[] { "string" }, 2) }; + + var state = SchemaCommand.BuildFieldsOnlyResult(2, fields); + var result = Assert.IsType(state.Result).Value; + + Assert.Equal(2, result.EnumerateObject().Count()); + Assert.Equal(2, result.GetProperty("sampledDocuments").GetInt32()); + Assert.Equal("id", result.GetProperty("fields")[0].GetProperty("path").GetString()); + Assert.Equal(new[] { "string" }, result.GetProperty("fields")[0].GetProperty("types").EnumerateArray().Select(type => type.GetString())); + Assert.Equal(2, result.GetProperty("fields")[0].GetProperty("presence").GetInt32()); + } + + private static SchemaCommand.FieldSchema Field(IReadOnlyList fields, string path) + { + return fields.Single(f => f.Path == path); + } + + private static List Parse(params string[] json) + { + return json.Select(text => + { + using var document = JsonDocument.Parse(text); + return document.RootElement.Clone(); + }).ToList(); + } +} diff --git a/CosmosDBShell.Tests/ToolOperationsTests.cs b/CosmosDBShell.Tests/ToolOperationsTests.cs index 26abae0..ee9861a 100644 --- a/CosmosDBShell.Tests/ToolOperationsTests.cs +++ b/CosmosDBShell.Tests/ToolOperationsTests.cs @@ -196,6 +196,27 @@ public void GetTool_MapsReadOnlyAnnotationHints() Assert.NotEqual(true, tool.Annotations.DestructiveHint); } + [Fact] + public void GetTool_SchemaExposesOptionsAndReadOnlyAnnotations() + { + var factory = new CommandRunner().Commands["schema"]; + + var tool = ToolOperations.GetTool(factory); + var properties = tool.InputSchema.GetProperty("properties"); + + Assert.Equal("integer", properties.GetProperty("sample").GetProperty("type").GetString()); + Assert.Equal("string", properties.GetProperty("database").GetProperty("type").GetString()); + Assert.Equal("string", properties.GetProperty("container").GetProperty("type").GetString()); + Assert.Equal("boolean", properties.GetProperty("fields-only").GetProperty("type").GetString()); + Assert.Contains("Aliases: short", properties.GetProperty("fields-only").GetProperty("description").GetString()); + Assert.NotNull(tool.Annotations); + Assert.Equal("Schema", tool.Annotations!.Title); + Assert.True(tool.Annotations.ReadOnlyHint); + Assert.True(tool.Annotations.IdempotentHint); + Assert.True(tool.Annotations.OpenWorldHint); + Assert.NotEqual(true, tool.Annotations.DestructiveHint); + } + [Fact] public void GetTool_MapsDestructiveAnnotationHint() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SchemaCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SchemaCommand.cs new file mode 100644 index 0000000..d8d0e69 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SchemaCommand.cs @@ -0,0 +1,287 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Commands; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Mcp; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.Util; +using global::Azure.Data.Cosmos.Shell.Core; +using global::Azure.Data.Cosmos.Shell.States; + +[CosmosCommand("schema")] +[CosmosExample("schema", Description = "Infer the schema of the current container from a small sample")] +[CosmosExample("schema --sample=50", Description = "Sample up to 50 documents when inferring the schema")] +[CosmosExample("schema --fields-only", Description = "Return only the inferred fields and sampled document count")] +[CosmosExample("schema --database=MyDB --container=Products", Description = "Infer the schema for a specific database and container")] +[McpAnnotation( + Title = "Schema", + ReadOnly = true, + Idempotent = true, + OpenWorld = true, + Description = "Returns a cheap, bounded discovery summary of a Cosmos DB container: partition key path(s), an indexing policy summary, an estimated document count, and inferred field types from a bounded sample of documents. Use fields-only (alias: short) to return only sampledDocuments and fields without reading container metadata. Use this before querying to avoid re-sampling and to avoid guessing field names.")] +internal class SchemaCommand : CosmosCommand +{ + internal const int DefaultSample = 20; + internal const int MinSample = 1; + internal const int MaxSample = 100; + + private const int DefaultMaxDepth = 8; + private const string ResourceUsageHeader = "x-ms-resource-usage"; + + [CosmosOption("database", "db")] + public string? Database { get; init; } + + [CosmosOption("container", "con")] + public string? Container { get; init; } + + [CosmosOption("sample", "s")] + public int? Sample { get; init; } + + [CosmosOption("fields-only", "short")] + public bool FieldsOnly { get; init; } + + public async override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + { + if (shell.State is not ConnectedState connectedState) + { + throw new NotConnectedException("schema"); + } + + var (databaseName, containerName, container) = ResolveContainerReference( + connectedState.Client, + shell.State, + this.Database, + this.Container, + "schema"); + + int sampleSize = NormalizeSample(this.Sample); + + try + { + ContainerResponse? containerResponse = null; + if (!this.FieldsOnly) + { + containerResponse = await container.ReadContainerAsync(new ContainerRequestOptions { PopulateQuotaInfo = true }, token); + } + + var sampledDocuments = await SampleDocumentsAsync(container, sampleSize, token); + var fields = InferSchema(sampledDocuments); + if (this.FieldsOnly) + { + return BuildFieldsOnlyResult(sampledDocuments.Count, fields); + } + + long? documentCountEstimate = InfoCommand.ParseResourceUsage(containerResponse!.Headers[ResourceUsageHeader]).DocumentCount; + + return BuildResult(databaseName, containerName, containerResponse.Resource, documentCountEstimate, sampleSize, sampledDocuments.Count, fields); + } + catch (CosmosException e) when (e.StatusCode == HttpStatusCode.NotFound) + { + throw new CommandException( + "schema", + MessageService.GetArgsString( + "error-container_not_found", + "container", + containerName, + "database", + databaseName), + e); + } + catch (Exception e) when (e is not OperationCanceledException) + { + throw new CommandException("schema", e); + } + } + + /// + /// Clamps the requested sample size into the supported .. + /// range so the discovery query stays bounded regardless of the value supplied. + /// + internal static int NormalizeSample(int? sample) + { + if (!sample.HasValue) + { + return DefaultSample; + } + + return Math.Clamp(sample.Value, MinSample, MaxSample); + } + + /// + /// Infers a per-field type summary from a bounded set of sampled documents. Fields are + /// reported using dot notation with at most path segments. + /// Each field lists the distinct JSON types observed and the number of sampled documents in + /// which the field was present. + /// + internal static IReadOnlyList InferSchema(IReadOnlyList documents, int maxDepth = DefaultMaxDepth) + { + var fields = new Dictionary(StringComparer.Ordinal); + + foreach (var document in documents) + { + if (document.ValueKind != JsonValueKind.Object) + { + continue; + } + + CollectFields(document, prefix: string.Empty, depth: 0, maxDepth, fields, new HashSet(StringComparer.Ordinal)); + } + + return fields + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => new FieldSchema(pair.Key, pair.Value.Types.ToArray(), pair.Value.Presence)) + .ToList(); + } + + private static void CollectFields( + JsonElement element, + string prefix, + int depth, + int maxDepth, + Dictionary fields, + HashSet fieldsSeenInDocument) + { + foreach (var property in element.EnumerateObject()) + { + string path = prefix.Length == 0 ? property.Name : $"{prefix}.{property.Name}"; + + if (!fields.TryGetValue(path, out var accumulator)) + { + accumulator = new FieldAccumulator(); + fields[path] = accumulator; + } + + accumulator.Types.Add(DescribeValueKind(property.Value.ValueKind)); + if (fieldsSeenInDocument.Add(path)) + { + accumulator.Presence++; + } + + if (property.Value.ValueKind == JsonValueKind.Object && depth + 1 < maxDepth) + { + CollectFields(property.Value, path, depth + 1, maxDepth, fields, fieldsSeenInDocument); + } + } + } + + private static string DescribeValueKind(JsonValueKind kind) => kind switch + { + JsonValueKind.String => "string", + JsonValueKind.Number => "number", + JsonValueKind.True or JsonValueKind.False => "boolean", + JsonValueKind.Object => "object", + JsonValueKind.Array => "array", + JsonValueKind.Null or JsonValueKind.Undefined => "null", + _ => "null", + }; + + private static async Task> SampleDocumentsAsync(Container container, int sample, CancellationToken token) + { + var documents = new List(sample); + using var iterator = container.GetItemQueryIterator( + new QueryDefinition(BuildSampleQueryText(sample)), + requestOptions: new QueryRequestOptions { MaxItemCount = sample }); + + while (iterator.HasMoreResults && documents.Count < sample) + { + foreach (var element in await iterator.ReadNextAsync(token)) + { + documents.Add(element.Clone()); + if (documents.Count >= sample) + { + break; + } + } + } + + return documents; + } + + internal static string BuildSampleQueryText(int sample) => FormattableString.Invariant($"SELECT TOP {sample} * FROM c"); + + internal static CommandState BuildFieldsOnlyResult(int sampledDocuments, IReadOnlyList fields) + { + var output = new Dictionary + { + ["sampledDocuments"] = sampledDocuments, + ["fields"] = BuildFieldsOutput(fields), + }; + + return new CommandState + { + Result = new ShellJson(JsonSerializer.SerializeToElement(output)), + }; + } + + internal static CommandState BuildResult( + string databaseName, + string containerName, + ContainerProperties properties, + long? documentCountEstimate, + int sampleSize, + int sampledDocuments, + IReadOnlyList fields) + { + Dictionary? indexingPolicy = properties.IndexingPolicy is { } indexing + ? new Dictionary + { + ["indexingMode"] = indexing.IndexingMode.ToString(), + ["automatic"] = indexing.Automatic, + ["includedPaths"] = indexing.IncludedPaths?.Count ?? 0, + ["excludedPaths"] = indexing.ExcludedPaths?.Count ?? 0, + ["compositeIndexes"] = indexing.CompositeIndexes?.Count ?? 0, + ["spatialIndexes"] = indexing.SpatialIndexes?.Count ?? 0, + ["vectorIndexes"] = indexing.VectorIndexes?.Count ?? 0, + } + : null; + + IReadOnlyList partitionKeyPaths = properties.PartitionKeyPaths?.ToArray() + ?? (properties.PartitionKeyPath != null ? [properties.PartitionKeyPath] : []); + + var output = new Dictionary + { + ["database"] = databaseName, + ["container"] = containerName, + ["partitionKeyPaths"] = partitionKeyPaths, + ["documentCountEstimate"] = documentCountEstimate, + ["sampleSize"] = sampleSize, + ["sampledDocuments"] = sampledDocuments, + ["indexingPolicy"] = indexingPolicy, + ["fields"] = BuildFieldsOutput(fields), + }; + + return new CommandState + { + Result = new ShellJson(JsonSerializer.SerializeToElement(output)), + }; + } + + private static List> BuildFieldsOutput(IReadOnlyList fields) + { + return fields.Select(field => new Dictionary + { + ["path"] = field.Path, + ["types"] = field.Types, + ["presence"] = field.Presence, + }).ToList(); + } + + /// + /// The inferred type summary for a single field discovered while sampling a container. + /// + internal sealed record FieldSchema(string Path, IReadOnlyList Types, int Presence); + + private sealed class FieldAccumulator + { + public SortedSet Types { get; } = new(StringComparer.Ordinal); + + public int Presence { get; set; } + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCommand.cs index b12be49..e58d78f 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCommand.cs @@ -39,6 +39,26 @@ internal abstract class CosmosCommand string? containerOption, string commandName, CancellationToken token) + { + var resolved = ResolveContainerReference(client, state, databaseOption, containerOption, commandName); + + // Validate that database and container exist + await ValidateContainerExistsAsync(RequireConnectedState(state, commandName), resolved.DatabaseName, resolved.ContainerName, commandName, token); + + return resolved; + } + + /// + /// Resolves a container reference from command options and current shell state without + /// performing an existence check. Commands that immediately read the container can use + /// that read as validation and avoid redundant service requests. + /// + protected static (string DatabaseName, string ContainerName, Container Container) ResolveContainerReference( + CosmosClient client, + State state, + string? databaseOption, + string? containerOption, + string commandName) { string? databaseName = null; string? containerName = null; @@ -72,11 +92,8 @@ internal abstract class CosmosCommand ThrowNotInContainer(commandName); } - // Validate that database and container exist - await ValidateContainerExistsAsync(RequireConnectedState(state, commandName), databaseName, containerName, commandName, token); - var container = client.GetDatabase(databaseName).GetContainer(containerName); - return (databaseName, containerName, container); + return (databaseName!, containerName!, container); } /// diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 726c82f..5c5d82c 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -393,6 +393,12 @@ command-mkcon-error_partition_key_slash = Partition key path must start with a f command-mkcon-error_invalid_index_policy = Invalid indexing policy JSON. Please provide a valid Cosmos DB indexing policy. command-mkcon-description-index_policy = The indexing policy as a JSON string. Follows the Cosmos DB indexing policy schema. +command-schema-description = Infers the schema of a container from a bounded sample and returns its partition key, indexing policy summary, an estimated document count, and inferred field types. +command-schema-description-database = The database containing the container +command-schema-description-container = The container to infer the schema for +command-schema-description-sample = Maximum number of documents to sample when inferring field types (1-100, default 20). +command-schema-description-fields-only = Return only sampledDocuments and inferred fields without reading container metadata. + command-index-description = Manages the indexing policy of a container via show, add, remove, and set subcommands. command-index-description-subcommand = The action to perform: show, add, remove, or set. command-index-description-paths = The indexing paths to add or remove, or a full indexing policy JSON document for set. diff --git a/README.md b/README.md index c315ca4..849478d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A terminal-native shell for Azure Cosmos DB — navigate databases like a filesy - Bulk roundtrip with `import` / `export` for JSON Lines and JSON array files, plus CSV import/export (CSV import coerces values to strings; `--partition-key` nests a CSV column under a nested partition key path) - Manage container indexing policies with `index` (`show`, `add`, `remove`, `set`) - Inspect container/database/account configuration and usage statistics with `info` (partition key, throughput, policies, indexing policy summary, document count, storage size, regions; `--partitions` and `--detailed` for distribution analysis) +- Discover a container's structure with `schema` — partition key, indexing policy summary, estimated document count, and inferred field types from a bounded sample (`--sample`, default 20); use `--fields-only`/`--short` for field-only output without a metadata read; also exposed as a read-only MCP tool - View and scale provisioned RU/s with `throughput` (`show`, `set`/`manual`, `autoscale`) - View and set the container default time-to-live with `ttl` (`show`, `set`, `on`, `off`) - View and set the container conflict resolution policy with `conflict` (`show`, `set`; last-writer-wins or custom stored procedure) diff --git a/docs/commands.md b/docs/commands.md index bb9e909..84ce6e6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -784,6 +784,81 @@ index set --mode=consistent --automatic=true index set '{"indexingMode":"consistent","automatic":true,"includedPaths":[{"path":"/*"}],"excludedPaths":[]}' ``` +### schema + +Infer the schema of a container from a small, bounded sample. The command returns the +partition key path(s), an indexing policy summary, an estimated document count, and the +field types inferred from the sample. It is read-only and uses a bounded sampling query +along with a container metadata read, making it a cheap way for agents and users to +discover a container's structure without re-sampling or guessing field names. + +```text +Usage: schema [-sample ] [-fields-only] [-database ] [-container ] + +Options: + -sample, -s Maximum number of documents to sample (1-100, default 20) + -fields-only, -short + Return only sampledDocuments and inferred fields without reading container metadata + -database, -db + Override database name (Optional) + -container, -con + Override container name (Optional) +``` + +By default the command targets the current container. Use `--database` and `--container` +to target a specific resource. The `--sample` value is clamped to the range 1-100 so the +discovery query stays bounded both server-side and in the client. + +Inferred fields use dot notation for nested objects (for example `address.city`). Each +field lists the distinct JSON types observed (`string`, `number`, `boolean`, `object`, +`array`, or `null`) and the number of sampled documents in which the field was present. +The `indexingPolicy` summary contains `indexingMode`, `automatic`, `includedPaths`, +`excludedPaths`, `compositeIndexes`, `spatialIndexes`, and `vectorIndexes`. + +#### Examples + +```bash +schema +schema --sample=50 +schema --fields-only +schema --short +schema --database=MyDB --container=Products +``` + +`--fields-only` (alias `--short`) skips the container metadata read and returns only +`sampledDocuments` and `fields`. This is useful when only field names and observed JSON +types are needed and a smaller CLI or MCP result is preferred. + +Short output: + +```json +{ + "sampledDocuments": 20, + "fields": [ + { "path": "id", "types": ["string"], "presence": 20 }, + { "path": "price", "types": ["null", "number"], "presence": 18 } + ] +} +``` + +Abbreviated sample output (the `indexingPolicy` summary is omitted for brevity): + +```json +{ + "database": "MyDB", + "container": "Products", + "partitionKeyPaths": ["/category"], + "documentCountEstimate": 1280, + "sampleSize": 20, + "sampledDocuments": 20, + "fields": [ + { "path": "id", "types": ["string"], "presence": 20 }, + { "path": "category", "types": ["string"], "presence": 20 }, + { "path": "price", "types": ["number"], "presence": 18 } + ] +} +``` + ### throughput View or change the provisioned throughput (RU/s) of a database or container through subcommands.