Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs
T
L. Elaine Dazzio 2ad0caf069 .NET: Fix JSON arrays of objects parsed as empty records when no schema is defined (#4199)
* fix: use HasSchema check in DetermineElementType to prevent empty records

When parsing JSON arrays containing objects without a predefined schema,
`DetermineElementType()` was creating a `VariableType` with an empty
(non-null) schema via `targetType.Schema?.Select(...) ?? []`. This caused
`ParseRecord` to take the schema-based parsing path, iterating over zero
schema fields and silently discarding all JSON properties.

The fix checks `targetType.HasSchema` and falls back to
`VariableType.RecordType` (which has `Schema = null`) when no schema is
defined, ensuring `ParseRecord` takes the dynamic `ParseValues()` path
that preserves all JSON properties.

Closes #4195

* test: add regression tests for schema-less JSON array-of-objects parsing (#4195)

Add two regression tests to JsonDocumentExtensionsTests:

1. ParseRecord_ObjectWithArrayOfObjects_NoSchema_PreservesNestedProperties
   - Parses a JSON object containing an array of objects using
     VariableType.RecordType (no schema) and verifies that nested
     object properties (name, role) are preserved in each element.
   - This is the exact scenario from issue #4195 where objects in
     arrays were being returned as empty dictionaries.

2. ParseList_ArrayOfObjects_NoSchema_PreservesProperties
   - Parses a JSON array of objects directly via ParseList with
     VariableType.ListType (no schema) and verifies all properties
     are preserved.

Both tests follow the existing Arrange/Act/Assert pattern and would
have failed before the DetermineElementType() fix (empty dictionaries
instead of populated ones).
2026-02-25 01:02:43 +00:00

300 lines
11 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
internal static class JsonDocumentExtensions
{
public static List<object?> ParseList(this JsonDocument jsonDocument, VariableType targetType)
{
return
jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Array => jsonDocument.RootElement.ParseTable(targetType),
JsonValueKind.Object when targetType.HasSchema => [jsonDocument.RootElement.ParseRecord(targetType)],
JsonValueKind.Null => [],
_ => [jsonDocument.RootElement.ParseValue(targetType)],
};
}
public static Dictionary<string, object?> ParseRecord(this JsonDocument jsonDocument, VariableType targetType)
{
if (!targetType.IsRecord)
{
throw new DeclarativeActionException($"Unable to convert JSON to object with requested type {targetType.Type.Name}.");
}
return
jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Array when targetType.HasSchema =>
((Dictionary<string, object?>?)jsonDocument.RootElement.ParseTable(targetType).Single()) ?? [],
JsonValueKind.Object => jsonDocument.RootElement.ParseRecord(targetType),
JsonValueKind.Null => [],
_ => throw new DeclarativeActionException($"Unable to convert JSON to object with requested type {targetType.Type.Name}."),
};
}
private static Dictionary<string, object?> ParseRecord(this JsonElement currentElement, VariableType targetType)
{
IEnumerable<KeyValuePair<string, object?>> keyValuePairs =
targetType.Schema is null ?
ParseValues() :
ParseSchema(targetType.Schema);
return keyValuePairs.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
IEnumerable<KeyValuePair<string, object?>> ParseValues()
{
foreach (JsonProperty objectProperty in currentElement.EnumerateObject())
{
if (!objectProperty.Value.TryParseValue(targetType: null, out object? parsedValue))
{
throw new DeclarativeActionException($"Unsupported data type '{objectProperty.Value.ValueKind}' for property '{objectProperty.Name}'");
}
yield return new KeyValuePair<string, object?>(objectProperty.Name, parsedValue);
}
}
IEnumerable<KeyValuePair<string, object?>> ParseSchema(FrozenDictionary<string, VariableType> schema)
{
foreach (KeyValuePair<string, VariableType> property in schema)
{
object? parsedValue = null;
if (!currentElement.TryGetProperty(property.Key, out JsonElement propertyElement))
{
if (!property.Value.Type.IsNullable())
{
throw new DeclarativeActionException($"Property '{property.Key}' undefined and not nullable.");
}
}
else if (!propertyElement.TryParseValue(property.Value, out parsedValue))
{
throw new DeclarativeActionException($"Unsupported data type '{property.Value.Type}' for property '{property.Key}'");
}
yield return new KeyValuePair<string, object?>(property.Key, parsedValue);
}
}
}
private static List<object?> ParseTable(this JsonElement currentElement, VariableType targetType)
{
if (!targetType.IsList)
{
throw new DeclarativeActionException($"Unable to convert JSON to list as requested type {targetType.Type.Name}.");
}
VariableType listType = DetermineElementType();
return
currentElement
.EnumerateArray()
.Select(element => element.ParseValue(listType))
.ToList();
VariableType DetermineElementType()
{
Type? targetElementType = targetType.Type.GetElementType();
VariableType? elementType = targetElementType is not null ? new(targetElementType) : null;
if (elementType is null)
{
foreach (JsonElement element in currentElement.EnumerateArray())
{
VariableType? currentType =
element.ValueKind switch
{
JsonValueKind.Object => targetType.HasSchema
? VariableType.Record(targetType.Schema!.Select(kvp => (kvp.Key, kvp.Value)))
: VariableType.RecordType,
JsonValueKind.String => typeof(string),
JsonValueKind.True => typeof(bool),
JsonValueKind.False => typeof(bool),
JsonValueKind.Number => typeof(decimal),
_ => null,
};
if (elementType is not null && currentType is not null && !elementType.Equals(currentType))
{
throw new DeclarativeActionException("Inconsistent element types in list.");
}
elementType ??= currentType;
}
}
return
elementType ??
throw new DeclarativeActionException("Unable to determine element type for list.");
}
}
private static object? ParseValue(this JsonElement propertyElement, VariableType targetType)
{
if (!propertyElement.TryParseValue(targetType, out object? value))
{
throw new DeclarativeActionException($"Unable to parse {propertyElement.ValueKind} as '{targetType.Type.Name}'");
}
return value;
}
private static bool TryParseValue(this JsonElement propertyElement, VariableType? targetType, out object? value) =>
propertyElement.ValueKind switch
{
JsonValueKind.String => TryParseString(propertyElement, targetType?.Type, out value),
JsonValueKind.Number => TryParseNumber(propertyElement, targetType?.Type, out value),
JsonValueKind.True or JsonValueKind.False => TryParseBoolean(propertyElement, out value),
JsonValueKind.Object => TryParseObject(propertyElement, targetType, out value),
JsonValueKind.Array => TryParseList(propertyElement, targetType, out value),
JsonValueKind.Null => TryParseNull(targetType?.Type, out value),
_ => throw new DeclarativeActionException($"JSON element of type {propertyElement.ValueKind} is not supported."),
};
private static bool TryParseNull(Type? valueType, out object? value)
{
// If the target type is not nullable, we cannot assign null to it
if (valueType?.IsNullable() == false)
{
value = null;
return false;
}
value = null;
return true;
}
private static bool TryParseBoolean(JsonElement propertyElement, out object? value)
{
try
{
value = propertyElement.GetBoolean();
return true;
}
catch
{
value = null;
return false;
}
}
private static bool TryParseString(JsonElement propertyElement, Type? valueType, out object? value)
{
try
{
string? propertyValue = propertyElement.GetString();
if (propertyValue is null)
{
value = null;
return valueType?.IsNullable() ?? false; // Parse fails if value is null and requested type is not.
}
if (valueType is null)
{
value = propertyValue;
}
else
{
switch (valueType)
{
case Type targetType when targetType == typeof(string):
value = propertyValue;
break;
case Type targetType when targetType == typeof(DateTime):
value = DateTime.Parse(propertyValue, provider: null, styles: DateTimeStyles.RoundtripKind);
break;
case Type targetType when targetType == typeof(TimeSpan):
value = TimeSpan.Parse(propertyValue);
break;
default:
value = null;
return false;
}
}
return true;
}
catch
{
value = null;
return false;
}
}
private static bool TryParseNumber(JsonElement element, Type? valueType, out object? value)
{
// Try parsing as integer types first (most precise representation)
if (element.TryGetInt32(out int intValue))
{
return ConvertToExpectedType(valueType, intValue, out value);
}
if (element.TryGetInt64(out long longValue))
{
return ConvertToExpectedType(valueType, longValue, out value);
}
// Try decimal for precise decimal values
if (element.TryGetDecimal(out decimal decimalValue))
{
return ConvertToExpectedType(valueType, decimalValue, out value);
}
// Fall back to double for other numeric values
if (element.TryGetDouble(out double doubleValue))
{
return ConvertToExpectedType(valueType, doubleValue, out value);
}
value = null;
return false;
static bool ConvertToExpectedType(Type? valueType, object sourceValue, out object? value)
{
if (valueType is null)
{
value = sourceValue;
return true;
}
try
{
value = Convert.ChangeType(sourceValue, valueType);
return true;
}
catch
{
value = null;
return false;
}
}
}
private static bool TryParseObject(JsonElement propertyElement, VariableType? targetType, out object? value)
{
value = propertyElement.ParseRecord(targetType ?? VariableType.RecordType);
return true;
}
private static bool TryParseList(JsonElement propertyElement, VariableType? targetType, out object? value)
{
try
{
value = ParseTable(propertyElement, targetType ?? VariableType.ListType);
return true;
}
catch
{
value = null;
return false;
}
}
}