-
Notifications
You must be signed in to change notification settings - Fork 8.1k
Add PSJsonSerializerV2 experimental feature for ConvertTo-Json using System.Text.Json #26637
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Add PSJsonSerializerV2 experimental feature for ConvertTo-Json using System.Text.Json #26637
Conversation
…erV2 experimental feature
…izerV2 is enabled
Based on iSazonov's feedback and testing, this commit refactors the V2 implementation to use the standard JsonSerializer.Serialize() with custom JsonConverter classes instead of the custom PowerShellJsonWriter. Key changes: - Removed PowerShellJsonWriter class (~500 lines of iterative serialization) - Implemented JsonSerializer.Serialize() with custom JsonConverters: - JsonConverterPSObject: Handles PSObject serialization - JsonConverterInt64Enum: Converts long/ulong enums to strings - JsonConverterNullString: Serializes NullString as null - JsonConverterDBNull: Serializes DBNull as null - Updated ConvertToJsonCommandV2: - Changed ValidateRange from (1, int.MaxValue) to (0, 1000) - Changed default Depth from int.MaxValue to 64 - Updated XML documentation to reflect System.Text.Json limitations - Deleted separate SystemTextJsonSerializer.cs file (integrated into ConvertToJsonCommand.cs) - Updated .gitignore to exclude test files Rationale: Testing revealed that Utf8JsonWriter has a hardcoded MaxDepth limit of 1000, making the custom iterative implementation unnecessary. Stack overflow is not a practical concern with this limit, as iSazonov correctly identified. Net result: ~400 lines of code removed while maintaining functionality. Status: - Build: Success - Basic tests: Passing - Full test suite: 4 passed, 9 failed (edge cases need fixing) - Known issues: NullString/DBNull serialization, BigInteger support This is a work-in-progress commit to preserve the refactoring effort. Further fixes needed for full test suite compliance.
Fixed issue where PSCustomObject properties with null values were
serialized as {prop:} instead of {prop:null}.
Root cause: The check 'value is null' does not detect PowerShells
AutomationNull.Value.
Solution: Changed to use LanguagePrimitives.IsNull(value) which
properly handles PowerShells null representations.
Test results:
- Before: 11 passed, 2 failed
- After: 12 passed, 1 failed, 1 skipped
- Remaining failure: DateTime timezone (acceptable per user guidance)
All critical functionality now working correctly.
Added ReferenceHandler.IgnoreCycles to JsonSerializerOptions to detect and handle circular references automatically using .NET's built-in mechanism. This provides partial mitigation for depth-related issues by preventing infinite loops in circular object graphs, though it does not fully solve the depth tracking issue for deeply nested non-circular objects (e.g., Type properties). Related to feedback from jborean93 regarding depth issues. Full depth tracking implementation will follow in a subsequent commit.
…ter for custom converter support
…r and more robust implementation
…s not explicitly set
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
| } | ||
|
|
||
| // Wrap in PSObject to ensure ETS properties are preserved | ||
| var pso = PSObject.AsPSObject(objectToProcess); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think intention is to use the cmdlet in pipeline. In the case all objects coming from pipeline already wrapped in PSObject.
Or it is single way the serializer can work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right that pipeline objects are already wrapped. However, -InputObject parameter objects are not:
$hash | ConvertTo-Json # $hash is wrapped in PSObject
ConvertTo-Json -InputObject $hash # $hash is NOT wrappedPSObject.AsPSObject() is cheap (returns same object if already wrapped), so it handles both cases safely.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As soon as STJ is extended so that we can use the standard serializer, we can reconsider this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we will process raw objects as raw objects (that is discussed in another comment) we should do the same in the point too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The entry point wraps input with PSObject.AsPSObject() (L213). Raw vs PSObject distinction is handled in nested object serialization via Option C implementation (WriteProperty and WriteValue check value is PSObject).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we should keep V1 InputObject behavior - process the object as raw.
Really we need two things - serialize PSObject and serialize raw object.
Due to the limitations of the standard serializer we cannot use it directly for serializing raw objects.
So we have to use custom reflection by means of wrapping to PSObject.
But we still have to distinguish whether the object was raw or originally PSObject.
I suggest using a simple trick - to wrap the raw object in a new auxiliary class and create a separate converter for it. Both converters will be able to re-use the same code with one difference when handling special properties.
Alternatively, we could add some flag to the PSObject instance and use one converter, but its code will be more complicated, but the main thing is if we get STJ with the extensions we need, we'll just delete the new auxiliary class and its converter without massive code change.
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Show resolved
Hide resolved
.../powershell/Modules/Microsoft.PowerShell.Utility/ConvertTo-Json.PSJsonSerializerV2.Tests.ps1
Outdated
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Show resolved
Hide resolved
| pso, | ||
| PSObject.GetPropertyCollection(memberTypes)); | ||
|
|
||
| foreach (var prop in properties) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use:
foreach (var prop in pso.Properties)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pso.Properties returns both Extended and Adapted properties. The current code filters by memberTypes to support -ExcludeBaseProperties, which needs Extended-only when $true.
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
| var options = new JsonSerializerOptions() | ||
| { | ||
| WriteIndented = !compressOutput, | ||
|
|
||
| // Set high value to avoid System.Text.Json exceptions | ||
| // User-specified depth is enforced by JsonConverterPSObject (max 100 via ValidateRange) | ||
| MaxDepth = 1000, | ||
| DefaultIgnoreCondition = JsonIgnoreCondition.Never, | ||
| Encoder = GetEncoder(stringEscapeHandling), | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From my understanding every instance of JsonSerializerOptions keep its own type cache. If it is true it makes sense to cache JsonSerializerOptions instances per psrunspace. The lazy cache need two parameter -compressOutput and stringEscapeHandling.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. Caching JsonSerializerOptions per runspace would improve performance. However, this requires architectural changes (runspace-level state management) that I think are beyond the scope of this PR. I'd like to address this in a follow-up PR if you agree.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can address this later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Acknowledged. Will address JsonSerializerOptions caching in a future PR.
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
| int currentDepth = writer.CurrentDepth; | ||
|
|
||
| // Handle special types - check for null-like objects (no depth increment needed) | ||
| if (LanguagePrimitives.IsNull(obj) || obj is DBNull or System.Management.Automation.Language.NullString) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we check LanguagePrimitives.IsNull(obj)? I don't see this in V1 code.
Will this change PSCustomObject processing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The LanguagePrimitives.IsNull() check handles AutomationNull.Value in addition to regular null. This is needed because PowerShell pipeline can produce AutomationNull.Value which should be serialized as JSON null. V1 handles this differently through Newtonsoft's converters. This doesn't change PSCustomObject processing - it only affects null-like values.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is a scenario for the check?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The check handles AutomationNull.Value when it becomes the BaseObject of a PSObject.
In C#, obj is null returns false for AutomationNull.Value (it's a singleton object), but LanguagePrimitives.IsNull(obj) returns true.
Scenario:
= [System.Management.Automation.Internal.AutomationNull]::Value
= [PSObject]::AsPSObject()
.BaseObject -eq # True
| ConvertTo-Json # Output: nullWithout this check, AutomationNull.Value would be serialized as an object instead of null.
See: L266
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AutomationNull is for Engine only. AutomationNull is intended for internal use only in the pipeline for null transmission. This should not be used explicitly.
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
| var psoItem = PSObject.AsPSObject(item); | ||
|
|
||
| // Recursive call - Write will handle depth tracking | ||
| Write(writer, psoItem, options); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we take into account if source object is raw or PSObject? My concern the wrapping will add extra properties (extended and adapted).
The question is for all place where we use the pattern.
I understand the trick as replacing reflection in standard STJ API with PSObject reflection.
Perhaps we should add specific property to the PSObject to indicate raw object and take it into account in PSObject converter to add or don't add extended/adapted properties.
This also assume that while we enumerating PSObject proprties we can check its kind and check for JsonIgnore.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tested several scenarios and the current implementation appears to serialize only the actual properties without adding extra Extended/Adapted properties. However, I may be missing edge cases. Please let me know if you see different behavior - I'd be happy to investigate further.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I compare outputs for FileInfo object in V1 version - 1. pipeline and 2.InputObject - they are different. What is result for V2? Also we could check a class with FileInfo property.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for the feedback. I investigated the V1 vs V2 behavior in detail.
Summary of Findings
Direct serialization (Guid, IPAddress)
| Type | V1 Pipeline | V1 InputObject | V2 Pipeline | V2 InputObject |
|---|---|---|---|---|
| Guid | object (2 props) | string | string | string |
| IPAddress | 10 props (w/ IPAddressToString) | 9 props (wo/ IPAddressToString) | 9 props | 9 props |
V1 issues: Pipeline vs InputObject produces different output for Guid and IPAddress.
V2 resolves these inconsistencies - Pipeline and InputObject produce identical output.
FileInfo as a class property
class TestClass { [System.IO.FileInfo]$File }
$obj = [TestClass]::new()
$obj.File = Get-Item $env:windir\notepad.exe # or [FileInfo]::new(...)
$obj | ConvertTo-Json -Depth 2| Source | V1 Pipeline | V1 InputObject | V2 Pipeline | V2 InputObject |
|---|---|---|---|---|
Get-Item (has Extended props) |
17 props | 17 props | 30 props | 30 props |
[FileInfo]::new() (no Extended props) |
17 props | 17 props | 24 props | 24 props |
V1: Always outputs Base properties only (17) because Newtonsoft.Json uses direct .NET reflection on nested objects.
V2: Outputs Base (17) + Adapted (7) + Extended (0-6), depending on whether the object was PSObject-wrapped.
Possible Approaches
| Approach | V1 Compatible | Trade-off |
|---|---|---|
| A. Keep current (hardcoded list) | No | Consistent, but requires manual updates for new types |
B. Base only (PSMemberViewTypes.Base) |
Yes | Loses Adapted props (Mode, VersionInfo, BaseName) |
| C. Track raw vs PSObject before wrapping | Partial | Replaces hardcoded list, needs careful implementation |
Approach A: The current V2 implementation uses a hardcoded IsPrimitiveType list (Guid, DateTime, Uri, BigInteger, etc.). This is not ideal for long-term maintenance as new .NET types require manual updates.
Approach C: I found we can use existing mechanisms to replace the hardcoded list:
- Check
item is PSObjectbefore callingPSObject.AsPSObject()to decide whether to include Extended properties - Use
MemberTypeto identify property categories:
| MemberType | Category | Example |
|---|---|---|
Property |
Base | Name, Length, FullName |
NoteProperty |
Extended | PSPath, PSDrive, PSProvider |
CodeProperty |
Adapted | Mode, ModeWithoutHardLink |
ScriptProperty |
Adapted | VersionInfo, BaseName |
Question
Which approach would you prefer?
- A: Keep current behavior (accept hardcoded list for now)
- B: Use Base only (V1 compatible, but loses Adapted props like Mode, VersionInfo)
- C: Implement raw vs PSObject tracking (replaces hardcoded list, partial V1 compatibility)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should preserve V1 capability:
- allow users to serialize raw objects with InputObject parameter
- serialize nested objects (property values) as raw object without adding extra properties.
It is C option.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in fdbe94ae6. Implemented Option C:
value is PSObject→ Serialize with Extended | Adapted properties- Raw .NET object → Serialize with Base properties only (V1 compatible)
Key changes:
- WriteValue L478-L489: checks
value is PSObject - WriteProperty L664-L677: checks
value is PSObject - SerializeRawValue L499-L539: new method for raw objects
- AppendBaseProperties L589-L611: filters to
MemberType == Property
V1 compatibility verified:
| Scenario | V1 | V2 |
|---|---|---|
| Nested raw FileInfo | 17 props | 17 props |
| Nested Get-Item FileInfo | 30 props | 30 props |
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
| try | ||
| { | ||
| var value = prop.Value; | ||
| writer.WritePropertyName(prop.Name); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need a specific processing here? Why not delegate the value processing to PSObject serializer?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The maxDepth == 0 check converts non-primitive values to strings, which is V1-compatible behavior for depth limiting. If we delegated to the PSObject serializer without this check, it would attempt full serialization and fail the depth check later. Handling it here provides better control and matches V1 behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With implementing option C I believe it will be changed. I'd still expect we do generic processing. V1 version does recursion here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in fdbe94a. The maxDepth == 0 handling works with Option C. See WritePropertyRaw L616-L642 which handles recursion via SerializeRawValue.
|
|
||
| // Note: JsonIgnoreAttribute check would require reflection on the underlying member | ||
| // which may not be available for all PSPropertyInfo types. For now, we rely on | ||
| // IsHidden to filter properties that should not be serialized. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enumerating properties in PSObject is reflection. So one more check is not add more problems.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in 0e07b33. Added JsonIgnoreAttribute check in ShouldSkipProperty. For PSProperty backed by MemberInfo, we now check for the attribute.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Maybe check NewtonSoft attribute too? We need discuss.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently only checking System.Text.Json.Serialization.JsonIgnoreAttribute: L694-L700
Question: Should we also check Newtonsoft.Json.JsonIgnoreAttribute for backward compatibility? V1 uses Newtonsoft, so users may have decorated their classes with that attribute.
…n, add nullability, use expression body, reorder null check, annotate IsNull, honor PSSerializeJSONLongEnumAsNumber, avoid double enumeration, add JsonIgnore check
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
| } | ||
|
|
||
| // Wrap in PSObject to ensure ETS properties are preserved | ||
| var pso = PSObject.AsPSObject(objectToProcess); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we will process raw objects as raw objects (that is discussed in another comment) we should do the same in the point too.
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Show resolved
Hide resolved
src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/ConvertToJsonCommandV2.cs
Outdated
Show resolved
Hide resolved
| int currentDepth = writer.CurrentDepth; | ||
|
|
||
| // Handle special types - check for null-like objects (no depth increment needed) | ||
| if (LanguagePrimitives.IsNull(obj) || obj is DBNull or System.Management.Automation.Language.NullString) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is a scenario for the check?
…Cmdlet/ConvertToJsonCommandV2.cs Co-authored-by: Ilya <darpa@yandex.ru>
…Cmdlet/ConvertToJsonCommandV2.cs Co-authored-by: Ilya <darpa@yandex.ru>
…Cmdlet/ConvertToJsonCommandV2.cs Co-authored-by: Ilya <darpa@yandex.ru>
SummaryAll requested changes have been implemented and tested.
*DateTime test fails due to timezone (existing issue, not related to this PR) Commits to push
|
Could you please point the test in new issue? |
@iSazonov This was a local environment issue on my machine. I shouldn't have mentioned it. Sorry for the confusion. |
|
As @iSazonov suggested:
Implemented
Removed hardcoded type list: With the raw/PSObject distinction in place, STJ limitation workarounds (not a type list):
Test Results:
|
iSazonov
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My previous idea was so that we could just delete the JsonConverterRawObject when it is not needed. But I see that a lot of code/logic is duplicated. We could get rid of this by using helper methods. If they complicate the code too much, we may choose a different path:
Only difference between JsonConverterPSObject and JsonConverterRawObject is in processing of psobject properties. I'd think to unify JsonConverterPSObject and JsonConverterRawObject, to make one PSJsonConverterObject where T is either PSOject or PSRawObjectWrapper. Then we could use this in condition type(T) == type(PSOject) or type(T) == type(RawObjectWrapper) to select appropriate code. In this case, removing the RawObjectWrapper will also be easy.
Or use a delegate for specific "processing of psobject properties".
| It 'Should serialize Guid as string consistently' { | ||
| $guid = [guid]"12345678-1234-1234-1234-123456789abc" | ||
| $jsonPipeline = $guid | ConvertTo-Json -Compress | ||
| $jsonInputObject = ConvertTo-Json -InputObject $guid -Compress | ||
| $jsonPipeline | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' | ||
| $jsonInputObject | Should -BeExactly '"12345678-1234-1234-1234-123456789abc"' | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean we have a breaking change with the scenario?
| $dict = @{ 1 = "one"; 2 = "two" } | ||
| $json = $dict | ConvertTo-Json -Compress | ||
| $json | Should -Match '"1":\s*"one"' | ||
| $json | Should -Match '"2":\s*"two"' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we use Compress we can explicitly compare with {"2":"two","1":"one"}.
I suggest use the pattern in (all) other tests too. It is more reliable and more readable. It is true for "-Not -Throw" - it is better replace its with such "positive" tests if possible.
| int currentDepth = writer.CurrentDepth; | ||
|
|
||
| // Handle special types - check for null-like objects (no depth increment needed) | ||
| if (LanguagePrimitives.IsNull(obj) || obj is DBNull or System.Management.Automation.Language.NullString) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AutomationNull is for Engine only. AutomationNull is intended for internal use only in the pipeline for null transmission. This should not be used explicitly.
| bool wroteStart = false; | ||
| foreach (var prop in etsProperties) | ||
| { | ||
| if (!JsonSerializerHelper.ShouldSkipProperty(prop)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please use the same pattern everywhere. See line 450 and 760.
| pso, | ||
| PSObject.GetPropertyCollection(PSMemberViewTypes.Extended)); | ||
|
|
||
| bool wroteStart = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the context (how we use it) I'd rename to isNotNull or reverse to bool isNull = true;
| } | ||
| else | ||
| { | ||
| var psoItem = PSObject.AsPSObject(item); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we do the same as in line 214? (Wrap as raw object if needed?)
| return; | ||
| } | ||
|
|
||
| var pso = PSObject.AsPSObject(value); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The same.
PR Summary
Reimplements
ConvertTo-Jsonusing System.Text.Json under thePSJsonSerializerV2experimental feature, with V1-compatible behavior.PR #26624 has been superseded by this PR. The approach has been refined based on @iSazonov's feedback to focus on V1 compatibility using System.Text.Json.
Fixes #5749
PR Context
Problem
When converting objects containing dictionaries with non-string keys (such as
Exception.Datawhich usesIDictionarywithobjectkeys),ConvertTo-JsonthrowsNonStringKeyInDictionaryerror.Solution
This implementation uses a custom JsonConverter for PSObject that handles depth control internally, as suggested by @iSazonov. Serialization is done entirely by System.Text.Json (Newtonsoft is only referenced for JObject compatibility and the existing
EscapeHandlingparameter type).Non-string dictionary keys: V2 converts all dictionary keys to strings via
ToString()during serialization. This allows dictionaries likeException.Data(which usesobjectkeys) to be serialized without errors.Key differences from V1:
The
-Depthparameter behavior:PR Checklist
.h,.cpp,.cs,.ps1and.psm1files have the correct copyright headerPSJsonSerializerV2Changes Made
New Files
ConvertToJsonCommandV2.cs(+618 lines)JsonConverterPSObjectwriter.CurrentDepthConvertTo-Json.PSJsonSerializerV2.Tests.ps1(+189 lines)Modified Files
ExperimentalFeature.cs(+5 lines)PSJsonSerializerV2experimental feature constantConvertToJsonCommand.cs(+4 lines)[Experimental(Hide)]attribute to hide V1 when V2 is enabledTesting
Existing Tests (ConvertTo-Json.Tests.ps1)
The existing test suite (14 tests) was verified with V2:
The single failure is a DateTime test with timezone-dependent expectations (test expects UTC-7, fails in other timezones). This is unrelated to V1/V2 changes.
New Tests (ConvertTo-Json.PSJsonSerializerV2.Tests.ps1)
V1/V2 Compatible (20 tests)
These tests verify that V2 behaves identically to V1:
V2 Only - Behavior Changes (5 tests)
These tests verify V2-specific improvements:
Exception.Dataserialize without error (Fixes Add Parameter to ConvertTo-Json to ignore unsupported properties #5749)-InputObject(ConvertTo-Json produces inconsistent output for Guid depending on input method #26635)Related Work
Previous PR
Directly Related Issues
Indirectly Related (ConvertFrom-Json / Depth handling)
Future Considerations
The following enhancements could be considered for future iterations:
ReferenceHandler.IgnoreCyclesto detect and handle circular references gracefullyJsonConverterinstances for specific types-JsonSerializerOptionsparameter - Exposing full STJ options for advanced scenarios (deferred per discussion with @iSazonov, prototype)