diff --git a/CosmosDBShell.Tests/CommandTests/ExportCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ExportCommandTests.cs index fe90cf7..b8f0ea6 100644 --- a/CosmosDBShell.Tests/CommandTests/ExportCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ExportCommandTests.cs @@ -204,6 +204,88 @@ public async Task WriteCsvAsync_NestedValuesWrittenAsCompactJson() Assert.Contains("\"{\"\"a\"\":\"\"b\"\"}\"", output); } + [Fact] + public async Task WriteCsvAsync_ObjectRowsKeepRawJsonValues() + { + var items = ToAsyncEnumerableAsync( + JsonSerializer.SerializeToElement(new { id = "1", flag = true, missing = (object?)null })); + + using var writer = new StringWriter(); + writer.NewLine = "\n"; + + var count = await ExportCommand.WriteCsvAsync(items, writer, ',', CancellationToken.None); + + Assert.Equal(1, count); + var lines = writer.ToString().TrimEnd('\n').Split('\n'); + Assert.Equal("\"id\",\"flag\",\"missing\"", lines[0]); + Assert.Equal("\"1\",\"true\",\"null\"", lines[1]); + } + + [Fact] + public async Task WriteCsvAsync_ScalarStringsUseValueColumnAndEscapeCsv() + { + var items = ToAsyncEnumerableAsync( + JsonSerializer.SerializeToElement("a,\"b")); + + using var writer = new StringWriter(); + writer.NewLine = "\n"; + + var count = await ExportCommand.WriteCsvAsync(items, writer, ',', CancellationToken.None); + + Assert.Equal(1, count); + Assert.Equal("\"value\"\n\"a,\"\"b\"\n", writer.ToString()); + } + + [Fact] + public async Task WriteCsvAsync_ScalarNumbersBoolsAndNullUseValueColumn() + { + var items = ToAsyncEnumerableAsync( + JsonSerializer.SerializeToElement(42), + JsonSerializer.SerializeToElement(true), + JsonSerializer.SerializeToElement(false), + JsonSerializer.SerializeToElement((object?)null)); + + using var writer = new StringWriter(); + writer.NewLine = "\n"; + + var count = await ExportCommand.WriteCsvAsync(items, writer, ',', CancellationToken.None); + + Assert.Equal(4, count); + Assert.Equal("\"value\"\n\"42\"\n\"True\"\n\"False\"\n\"\"\n", writer.ToString()); + } + + [Fact] + public async Task WriteCsvAsync_ArrayRowsUseValueColumn() + { + var items = ToAsyncEnumerableAsync( + JsonSerializer.SerializeToElement(new[] { 1, 2 })); + + using var writer = new StringWriter(); + writer.NewLine = "\n"; + + var count = await ExportCommand.WriteCsvAsync(items, writer, ',', CancellationToken.None); + + Assert.Equal(1, count); + Assert.Equal("\"value\"\n\"[1,2]\"\n", writer.ToString()); + } + + [Fact] + public async Task WriteCsvAsync_MixedObjectAndScalarRowsPreserveScalarValue() + { + var items = ToAsyncEnumerableAsync( + JsonSerializer.SerializeToElement(new { id = "1" }), + JsonSerializer.SerializeToElement("a,b"), + JsonSerializer.SerializeToElement(new { id = "2", value = 99 })); + + using var writer = new StringWriter(); + writer.NewLine = "\n"; + + var count = await ExportCommand.WriteCsvAsync(items, writer, ',', CancellationToken.None); + + Assert.Equal(3, count); + Assert.Equal("\"id\",\"value\"\n\"1\",\"\"\n\"\",\"a,b\"\n\"2\",\"99\"\n", writer.ToString()); + } + [Fact] public async Task WriteCsvAsync_WithNoItems_ProducesEmptyOutput() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs index 1d1707d..126c091 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs @@ -37,6 +37,7 @@ internal enum ExportFormat internal class ExportCommand : CosmosCommand { private const string DefaultQuery = "SELECT * FROM c"; + private const string ValueColumnName = "value"; [CosmosParameter("file", RequiredErrorKey = "command-export-error-missing_file")] public string? File { get; init; } @@ -187,8 +188,9 @@ internal static async Task WriteArrayAsync(IAsyncEnumerable it /// /// Writes a sequence of items to as CSV. The header row is the /// union of all top-level property names (in first-seen order); each subsequent row - /// contains the corresponding values. Nested objects and arrays are written as compact - /// JSON. Items are spooled to disk to compute the column set. + /// contains the corresponding values. Non-object rows are written in a value + /// column. Nested objects and arrays are written as compact JSON. Items are spooled to + /// disk to compute the column set. /// /// The items to write. /// The destination writer. @@ -218,6 +220,11 @@ internal static async Task WriteCsvAsync(IAsyncEnumerable item await spoolWriter.WriteLineAsync(SerializeJsonLine(item).AsMemory(), token); if (item.ValueKind != JsonValueKind.Object) { + if (headerSet.Add(ValueColumnName)) + { + headers.Add(ValueColumnName); + } + continue; } @@ -267,6 +274,10 @@ internal static async Task WriteCsvAsync(IAsyncEnumerable item var text = value.ValueKind == JsonValueKind.String ? value.GetString() ?? string.Empty : value.GetRawText(); sb.Append(CommandState.EscapeCSV(text)); } + else if (item.ValueKind != JsonValueKind.Object && headers[i] == ValueColumnName) + { + sb.Append(CommandState.EscapeCSV(GetScalarCsvValue(item))); + } else { sb.Append(CommandState.EscapeCSV(string.Empty)); @@ -281,6 +292,11 @@ internal static async Task WriteCsvAsync(IAsyncEnumerable item return count; } + private static string GetScalarCsvValue(JsonElement item) + { + return item.ToString(); + } + private static async Task<(int Count, double Charge)> ExecuteExportAsync( Container container, string query, diff --git a/docs/commands.md b/docs/commands.md index dd6a476..8e63e9c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -654,7 +654,7 @@ Examples: ### export -Stream items from a container to a local file. Default format is JSON Lines (one compact JSON object per line); pass `--format=array` for a single JSON array, or `--format=csv` for CSV. JSON formats stream incrementally. CSV spools documents to a private temporary file to compute the complete column set, keeping only the column names and current record in memory. Allow enough temporary disk space for the JSON spool as well as the destination export. The CSV separator follows the `COSMOSDB_SHELL_CSVSEP` environment variable (default `;`). +Stream items from a container to a local file. Default format is JSON Lines (one compact JSON object per line); pass `--format=array` for a single JSON array, or `--format=csv` for CSV. JSON formats stream incrementally. CSV spools documents to a private temporary file to compute the complete column set, keeping only the column names and current record in memory. Allow enough temporary disk space for the JSON spool as well as the destination export. The CSV separator follows the `COSMOSDB_SHELL_CSVSEP` environment variable (default `;`). Non-object query results are written to a CSV `value` column; mixed object and non-object results include both object property columns and the `value` column. All formats write to a temporary file in the destination directory and move it into place only after successful completion. An existing destination requires `--force` and is preserved if reading, writing, or cancellation interrupts the export. Temporary files are removed on normal completion and handled failures; an abrupt process termination can leave an unfinished destination-directory temporary file. Once `--max` items have been emitted, no further query pages are requested.