Skip to content
Open
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
82 changes: 82 additions & 0 deletions CosmosDBShell.Tests/CommandTests/ExportCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
20 changes: 18 additions & 2 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -187,8 +188,9 @@ internal static async Task<int> WriteArrayAsync(IAsyncEnumerable<JsonElement> it
/// <summary>
/// Writes a sequence of items to <paramref name="writer"/> 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 <c>value</c>
/// column. Nested objects and arrays are written as compact JSON. Items are spooled to
/// disk to compute the column set.
/// </summary>
/// <param name="items">The items to write.</param>
/// <param name="writer">The destination writer.</param>
Expand Down Expand Up @@ -218,6 +220,11 @@ internal static async Task<int> WriteCsvAsync(IAsyncEnumerable<JsonElement> item
await spoolWriter.WriteLineAsync(SerializeJsonLine(item).AsMemory(), token);
if (item.ValueKind != JsonValueKind.Object)
{
if (headerSet.Add(ValueColumnName))
{
headers.Add(ValueColumnName);
}

continue;
}

Expand Down Expand Up @@ -267,6 +274,10 @@ internal static async Task<int> WriteCsvAsync(IAsyncEnumerable<JsonElement> 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));
Expand All @@ -281,6 +292,11 @@ internal static async Task<int> WriteCsvAsync(IAsyncEnumerable<JsonElement> 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,
Expand Down
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading