Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/http-client-csharp"
---

Generator now emits V2 `PropagateGet` pattern for list/array properties backed by CLR types: adds an `IsEmpty` check at the outermost array level and generates `TryResolve{PropertyName}Array` / `Active{PropertyName}` helpers to support `JsonPatch.EnumerateArray` on CLR-backed collections.
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ private MethodBodyStatement[] BuildDynamicPropertyIfStatements(
return [.. statements];
}

private static MethodBodyStatement[] BuildCollectionIfStatements(
private MethodBodyStatement[] BuildCollectionIfStatements(
PropertyProvider property,
bool propagateGet,
ParameterProvider valueParameter,
Expand All @@ -392,6 +392,7 @@ private static MethodBodyStatement[] BuildCollectionIfStatements(
var currentType = property.Type;
var accessorChain = new List<ValueExpression> { new IndexableExpression(property) };
ValueExpression? remainderSlice = null;
bool isFirstLevel = true;

statements.Add(Declare("propertyLength", typeof(int),
LiteralU8(GetJsonSerializedName(property.WireInfo!)).Property("Length"),
Expand All @@ -407,6 +408,20 @@ private static MethodBodyStatement[] BuildCollectionIfStatements(

if (currentType.IsList || currentType.IsArray)
{
// V2: when PropagateGet and the outermost element is a direct dynamic model,
// add an IsEmpty check so that array-level paths (e.g. "$.properties.items")
// can be resolved via serialization of the CLR collection.
if (propagateGet && isFirstLevel && IsDirectDynamicListProperty(property))
{
string tryResolveMethodName = $"TryResolve{property.Name}Array";
VariableExpression valueVar = valueParameter;
statements.Add(new IfStatement(currentSlice.Property("IsEmpty"))
{
Return(new InvokeMethodExpression(null, tryResolveMethodName,
[new VariableExpression(valueVar.Type, valueVar.Declaration, IsOut: true)]))
});
}

statements.Add(new IfStatement(Not(currentSlice.Invoke(
"TryGetIndex",
[
Expand Down Expand Up @@ -457,6 +472,7 @@ private static MethodBodyStatement[] BuildCollectionIfStatements(
}

currentType = currentType.ElementType;
isFirstLevel = false;
}

var finalAccessor = accessorChain.Last();
Expand All @@ -481,6 +497,127 @@ private static MethodBodyStatement[] BuildCollectionIfStatements(

return [.. statements];
}

/// <summary>
/// Returns true if the property is a list or array whose direct element type is a dynamic model with a Patch property.
/// Only such properties support the V2 IsEmpty / TryResolveArray pattern.
/// </summary>
private static bool IsDirectDynamicListProperty(PropertyProvider property)
{
if (!property.Type.IsList && !property.Type.IsArray)
return false;
return ScmCodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.TryGetValue(
property.Type.ElementType,
out var provider) &&
provider is ScmModelProvider { JsonPatchProperty: not null };
}

/// <summary>
/// Builds the <c>TryResolve{PropertyName}Array</c> helper method that serializes the active
/// (non-removed) CLR items as a JSON array and returns an <see cref="JsonPatch.EncodedValue"/>.
/// </summary>
private MethodProvider BuildTryResolveArrayMethod(PropertyProvider property)
{
var valueParameter = new ParameterProvider("value", $"", typeof(JsonPatch.EncodedValue), isOut: true);

var signature = new MethodSignature(
$"TryResolve{property.Name}Array",
$"",
MethodSignatureModifiers.Private,
typeof(bool),
$"",
[valueParameter]);

var dataDeclStatement = Declare("data", typeof(BinaryData),
Static(typeof(ModelReaderWriter)).Invoke(nameof(ModelReaderWriter.Write), [
new InvokeMethodExpression(null, $"Active{property.Name}", []),
New.Instance(typeof(ModelReaderWriterOptions), Literal("J"))
]),
out var dataVar);

var tempPatchDeclStatement = Declare("tempPatch", typeof(JsonPatch), New.Instance(typeof(JsonPatch)), out var tempPatchVar);

var bodyStatements = new MethodBodyStatement[]
{
valueParameter.Assign(Default).Terminate(),
dataDeclStatement,
tempPatchDeclStatement,
tempPatchVar.As<JsonPatch>().Set(LiteralU8("$"), dataVar.Invoke("ToMemory").Property("Span")),
Return(tempPatchVar.As<JsonPatch>().TryGetEncodedValue(LiteralU8("$"), valueParameter))
};

return new MethodProvider(
signature,
bodyStatements,
_model,
suppressions: [ScmModelProvider.JsonPatchSuppression]);
}

/// <summary>
/// Builds the <c>Active{PropertyName}</c> iterator that yields non-removed items from the CLR collection.
/// </summary>
private MethodProvider BuildActiveItemsMethod(PropertyProvider property)
{
var elementType = property.Type.ElementType;
var returnType = new CSharpType(typeof(IEnumerable<>), elementType);

var signature = new MethodSignature(
$"Active{property.Name}",
$"",
MethodSignatureModifiers.Private,
returnType,
$"",
[]);

var propertyExpr = new IndexableExpression(property);
string lengthPropertyName = property.Type.IsArray ? "Length" : "Count";

var indexDeclaration = Declare<int>("i", out var indexVar);
var forStatement = new ForStatement(
indexDeclaration.Assign(Literal(0)),
indexVar.LessThan(((ValueExpression)property).Property(lengthPropertyName)),
indexVar.Increment())
{
new IfStatement(Not(propertyExpr[indexVar].Property("Patch").As<JsonPatch>().IsRemoved(LiteralU8("$"))))
{
YieldReturn(propertyExpr[indexVar])
}
};

var bodyStatements = new MethodBodyStatement[]
{
new IfStatement(Not(OptionalSnippets.IsCollectionDefined((ValueExpression)property)))
{
YieldBreak()
},
forStatement
};

return new MethodProvider(
signature,
bodyStatements,
_model,
suppressions: [ScmModelProvider.JsonPatchSuppression]);
}

/// <summary>
/// Returns all collection properties from this model and its base models that qualify for the
/// V2 PropagateGet pattern (outermost element is a direct dynamic model with a Patch property).
/// </summary>
private List<PropertyProvider> GetQualifyingDynamicListProperties()
{
var result = new List<PropertyProvider>();
var currentModel = _model;
while (currentModel != null)
{
var qualifyingProps = currentModel.CanonicalView.Properties
.Where(p => p.WireInfo != null && IsDirectDynamicListProperty(p));
result.AddRange(qualifyingProps);
currentModel = currentModel.BaseModelProvider as ScmModelProvider;
}
return result;
}

#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.

private static string BuildJsonPathForElement(string propertySerializedName, List<ValueExpression> indices)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,12 @@ protected override MethodProvider[] BuildMethods()
if (_model is ScmModelProvider { IsDynamicModel: true, HasDynamicProperties: true })
{
methods.AddRange(BuildPropagateGetMethod(), BuildPropagateSetMethod());

// Add V2 helper methods for every qualifying list/array property
foreach (var prop in GetQualifyingDynamicListProperties())
{
methods.AddRange(BuildTryResolveArrayMethod(prop), BuildActiveItemsMethod(prop));
}
}

return [.. methods];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,41 @@ public void PropagateModelListProperty()
Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content);
}

[Test]
public void PropagateModelListPropertyHelperMethods()
{
var inputModel = InputFactory.Model(
"dynamicModel",
isDynamicModel: true,
properties:
[
InputFactory.Property("p1",
InputFactory.Array(InputFactory.Model(
"anotherDynamic",
isDynamicModel: true,
properties:
[
InputFactory.Property("a1", InputPrimitiveType.String, isRequired: true)
])))
]);

MockHelpers.LoadMockGenerator(inputModels: () => [inputModel]);
var model = ScmCodeModelGenerator.Instance.TypeFactory.CreateModel(inputModel) as ClientModel.Providers.ScmModelProvider;

Assert.IsNotNull(model);
Assert.IsTrue(model!.IsDynamicModel);
var serialization = model.SerializationProviders.SingleOrDefault();
Assert.IsNotNull(serialization);

var writer = new TypeProviderWriter(new FilteredMethodsTypeProvider(
serialization!,
name => name is "TryResolveP1Array" or "ActiveP1"));

var file = writer.Write();

Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content);
}

[Test]
public void PropagateModelDictionaryProperty()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ private bool PropagateGet(global::System.ReadOnlySpan<byte> jsonPath, out global
{
int propertyLength = "prop1"u8.Length;
global::System.ReadOnlySpan<byte> currentSlice = local.Slice(propertyLength);
if (currentSlice.IsEmpty)
{
return TryResolveProp2Array(out value);
}
if (!currentSlice.TryGetIndex(out int index, out int bytesConsumed))
{
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ private bool PropagateGet(global::System.ReadOnlySpan<byte> jsonPath, out global
{
int propertyLength = "prop1"u8.Length;
global::System.ReadOnlySpan<byte> currentSlice = local.Slice(propertyLength);
if (currentSlice.IsEmpty)
{
return TryResolveProp2Array(out value);
}
if (!currentSlice.TryGetIndex(out int index, out int bytesConsumed))
{
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ private bool PropagateGet(global::System.ReadOnlySpan<byte> jsonPath, out global
{
int propertyLength = "p1"u8.Length;
global::System.ReadOnlySpan<byte> currentSlice = local.Slice(propertyLength);
if (currentSlice.IsEmpty)
{
return TryResolveP1Array(out value);
}
if (!currentSlice.TryGetIndex(out int index, out int bytesConsumed))
{
return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// <auto-generated/>

#nullable disable

using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using Sample.Models;

namespace Sample
{
public partial class DynamicModel
{
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
private bool TryResolveP1Array(out global::System.ClientModel.Primitives.JsonPatch.EncodedValue value)
{
value = default;
global::System.BinaryData data = global::System.ClientModel.Primitives.ModelReaderWriter.Write(ActiveP1(), new global::System.ClientModel.Primitives.ModelReaderWriterOptions("J"));
global::System.ClientModel.Primitives.JsonPatch tempPatch = new global::System.ClientModel.Primitives.JsonPatch();
tempPatch.Set("$"u8, data.ToMemory().Span);
return tempPatch.TryGetEncodedValue("$"u8, out value);
}
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.

#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
private global::System.Collections.Generic.IEnumerable<global::Sample.Models.AnotherDynamic> ActiveP1()
{
if (!global::Sample.Optional.IsCollectionDefined(P1))
{
yield break;
}
for (int i = 0; (i < P1.Count); i++)
{
if (!P1[i].Patch.IsRemoved("$"u8))
{
yield return P1[i];
}
}
}
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ private bool PropagateGet(global::System.ReadOnlySpan<byte> jsonPath, out global
{
int propertyLength = "p2"u8.Length;
global::System.ReadOnlySpan<byte> currentSlice = local.Slice(propertyLength);
if (currentSlice.IsEmpty)
{
return TryResolveP2Array(out value);
}
if (!currentSlice.TryGetIndex(out int index, out int bytesConsumed))
{
return false;
Expand All @@ -42,6 +46,10 @@ private bool PropagateGet(global::System.ReadOnlySpan<byte> jsonPath, out global
{
int propertyLength = "p3"u8.Length;
global::System.ReadOnlySpan<byte> currentSlice = local.Slice(propertyLength);
if (currentSlice.IsEmpty)
{
return TryResolveP3Array(out value);
}
if (!currentSlice.TryGetIndex(out int index, out int bytesConsumed))
{
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,10 @@ private bool PropagateGet(ReadOnlySpan<byte> jsonPath, out JsonPatch.EncodedValu
{
int propertyLength = "listFoo"u8.Length;
ReadOnlySpan<byte> currentSlice = local.Slice(propertyLength);
if (currentSlice.IsEmpty)
{
return TryResolveListFooArray(out value);
}
if (!currentSlice.TryGetIndex(out int index, out int bytesConsumed))
{
return false;
Expand Down Expand Up @@ -966,5 +970,38 @@ private bool PropagateSet(ReadOnlySpan<byte> jsonPath, JsonPatch.EncodedValue va
return false;
}
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.

/// <summary></summary>
/// <param name="value"></param>
/// <returns></returns>
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
private bool TryResolveListFooArray(out JsonPatch.EncodedValue value)
{
value = default;
BinaryData data = ModelReaderWriter.Write(ActiveListFoo(), new ModelReaderWriterOptions("J"));
JsonPatch tempPatch = new JsonPatch();
tempPatch.Set("$"u8, data.ToMemory().Span);
return tempPatch.TryGetEncodedValue("$"u8, out value);
}
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.

/// <summary></summary>
/// <returns></returns>
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
private IEnumerable<AnotherDynamicModel> ActiveListFoo()
{
if (!Optional.IsCollectionDefined(ListFoo))
{
yield break;
}
for (int i = 0; (i < ListFoo.Count); i++)
{
if (!ListFoo[i].Patch.IsRemoved("$"u8))
{
yield return ListFoo[i];
}
}
}
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates.
}
}
Loading