Skip to content

Conversation

@yotsuda
Copy link
Contributor

@yotsuda yotsuda commented Jan 3, 2026

PR Summary

Implements TruncatingConverterFactory pattern for ConvertTo-Json V2 as discussed with @iSazonov in PR #26637.

This PR is an alternative implementation approach based on the Factory pattern, branched from feature-convertto-json-isazonov-approach.

Fixes #5749

PR Context

Background

In PR #26637, @iSazonov suggested implementing the Factory approach in a new PR to evaluate whether it provides a better architecture than the current implementation.

"I can't say for sure which approach will be the best for us in the end. Therefore, it is better to implement the factory approach with a new PR."

Thanks to @iSazonov's guidance on the technical considerations, this implementation now works correctly.

Solution

This implementation uses a TruncatingConverterFactory that dispatches to type-specific converters:

Type Converter
PSObject JsonConverterPSObject (Extended/Adapted properties)
IDictionary JsonConverterDictionary<T> (Base properties only)
IEnumerable JsonConverterEnumerable<T>
Composite objects JsonConverterComposite<T> (Base properties only)
Primitives STJ default converters

Architecture Benefits

  1. Type-level dispatch - STJ caches converters per type, improving performance
  2. Clear separation - Each converter handles one concern
  3. Extensibility - Easy to add new type-specific converters

Key Differences from PR #26637

Aspect PR #26637 This PR (Factory)
Raw object handling RawObjectWrapper class TruncatingConverterFactory dispatch
Type dispatch Manual in WriteValue Factory pattern with generic converters
Converter structure Single JsonConverterPSObject with _basePropertiesOnly flag Separate converters per type category

PR Checklist

  • PR has a meaningful title
  • Summarized changes
  • Make sure all .h, .cpp, .cs, .ps1 and .psm1 files have the correct copyright header
  • This PR is ready to merge
  • Breaking changes: Experimental feature needed
    • Experimental feature name: PSJsonSerializerV2
  • User-facing changes: Documentation needed
  • Testing: New tests added

Changes Made

Modified Files

ConvertToJsonCommandV2.cs

  • Added TruncatingConverterFactory - dispatches to appropriate converters based on type
  • Added JsonConverterDictionary<T> - handles IDictionary types
  • Added JsonConverterEnumerable<T> - handles IEnumerable types
  • Added JsonConverterComposite<T> - handles composite objects with Base properties only
  • Removed RawObjectWrapper class
  • Removed _basePropertiesOnly field and filterToPropertyOnly parameter from JsonConverterPSObject
  • Added WriteValue helper to JsonSerializerHelper

ConvertTo-Json.PSJsonSerializerV2.Tests.ps1

  • Removed array element test that now matches V1 behavior

Testing

Existing Tests (ConvertTo-Json.Tests.ps1)

  • 36 passed, 1 failed, 1 pending
  • The single failure is a DateTime test with timezone-dependent expectations (unrelated to this change)

V2 Specific Tests (ConvertTo-Json.PSJsonSerializerV2.Tests.ps1)

  • 4 passed, 0 failed

Related Work

PR/Issue Description
#26637 Original implementation (this PR is an alternative approach)
#5749 Dictionary with non-string keys handling

Branch Structure

gitGraph
    commit id: "master"
    branch system-text-json
    commit id: "77d8a1b0c"
    commit id: "159b76387"
    branch isazonov-approach
    commit id: "e7dfff962"
    commit id: "da0c8cbfb"
    branch factory-test
    commit id: "test" type: REVERSE
    checkout isazonov-approach
    commit id: "6913cb119"
    commit id: "2ecc51e05"
    commit id: "2c549207a" tag: "PR #26637"
    branch factory-v2
    commit id: "af393269b" tag: "HEAD"
    checkout system-text-json
    commit id: "07cea1dc5" tag: "PR #26624"
Loading
Branch Base PR Status
system-text-json master #26624 Superseded
isazonov-approach system-text-json #26637 Open
factory-test isazonov-approach - Experiment (failed)
factory-v2 isazonov-approach This PR ✅ Success

@yotsuda yotsuda marked this pull request as ready for review January 3, 2026 00:04
Copy link
Collaborator

@iSazonov iSazonov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yotsuda Thanks for starting the approach!

@yotsuda yotsuda force-pushed the feature-convertto-json-factory-v2 branch from 74a83c8 to 783f3b3 Compare January 4, 2026 05:03
@iSazonov
Copy link
Collaborator

iSazonov commented Jan 4, 2026

@yotsuda It seems you lost last commits - I don't see updates.

@yotsuda
Copy link
Contributor Author

yotsuda commented Jan 4, 2026

Copy link
Collaborator

@iSazonov iSazonov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yotsuda Great progress! Thanks!


// Convert to string when depth exceeded
string stringValue = LanguagePrimitives.ConvertTo<string>(pso.ImmediateBaseObjectIsEmpty ? pso : obj);
writer.WriteStringValue(stringValue);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems you don't understand my request. I mean if max depth is reached, V1 convert base object to string and process extended/adapted properties too. In V2 we only convert base object to string but don't process extended/adapted properties.

@iSazonov
Copy link
Collaborator

iSazonov commented Jan 5, 2026

Please see failed tests.
For "AddMember on JsonObject" I see:

$jstr
{
  "Major": 2,
  "Minor": 3,
  "Build": 4,
  "Revision": 14,
  "MajorRevision": 0,
  "MinorRevision": 14,
  "Note": "a version object",
  "Rev": 14,
  "IsOld": true
}

What is $jstr in V2?

As for 127 serialization I think we can select expected result based on the experimental feature status.

@iSazonov iSazonov added the CL-General Indicates that a PR should be marked as a general cmdlet change in the Change Log label Jan 5, 2026
Copy link
Collaborator

@iSazonov iSazonov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yotsuda Thanks for your efforts! I didn't expect that there would be so many difficulties, but it seems that we have already overcome most of them.


// GetTypeInfo() has internal caching, no need for additional cache
var typeInfo = JsonSerializerOptions.Default.GetTypeInfo(type);
return typeInfo.Kind == JsonTypeInfoKind.None;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public static bool ShouldSkipProperty(PSPropertyInfo prop)
{
// Check for Hidden attribute
if (prop.IsHidden)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, how this (doesn't) works for script-defined classes.

@yotsuda
Copy link
Contributor Author

yotsuda commented Jan 6, 2026

Please see failed tests. For "AddMember on JsonObject" I see:
...
What is $jstr in V2?
As for 127 serialization I think we can select expected result based on the experimental feature status.

Both issues are fixed in the latest commits.

AddMember on JsonObject (Version + ETS):

V2 now produces the same output as V1 for Version objects with ETS properties:

$versionObject = New-Object System.Version 2, 3, 4, 14
$versionObject | Add-Member -MemberType NoteProperty -Name Note -Value "a version object"
$versionObject | Add-Member -MemberType ScriptProperty -Name Rev -Value { $this.Revision }
$versionObject | Add-Member -MemberType ScriptProperty -Name IsOld -Value { $this.Major -lt 3 }
$versionObject | ConvertTo-Json

Output (both V1 and V2):
{
  "Major": 2,
  "Minor": 3,
  "Build": 4,
  "Revision": 14,
  "MajorRevision": 0,
  "MinorRevision": 14,
  "Note": "a version object",
  "Rev": 14,
  "IsOld": true
}

The fix adds V1-compatible logic: "true primitive types" (string, DateTime, etc.) always serialize as scalars ignoring ETS, while non-primitive STJ scalar types (Version, IPAddress, etc.) serialize as objects when they have ETS properties.

[char]127 serialization:

Implemented as you suggested - the test now selects expected result based on experimental feature status:

$expectedToJson = $testCase.ToJson
if ($testCase.ToJsonV2 -and (Get-ExperimentalFeature PSJsonSerializerV2).Enabled)
{
    $expectedToJson = $testCase.ToJsonV2
}
  • V1: "" (DEL control character disappears)
  • V2: "\u007F" (correct Unicode escape)

Commit: 8efb723

Copy link
Collaborator

@iSazonov iSazonov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yotsuda Thanks! It seems I see final :-)

I expect you to address all the previous comments that are not closed. After that, we should work on compatibility tests and come back here to complete the work.

return false;
}

// V1 primitive types always serialize as scalars, ignoring ETS properties
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure? I see V1 code (ProcessValue method) skip ETS in AddPsProperties method only if:

  1. isPureObj = true (LanguagePrimitives.ConvertTo() add ETS)
  2. it is not PSObject (it is raw object)
  3. It is string or Datetime type.

private void SerializeAsObject(Utf8JsonWriter writer, PSObject pso, JsonSerializerOptions options)
{
writer.WriteStartObject();
AppendPSProperties(writer, pso, options, PSMemberViewTypes.Extended | PSMemberViewTypes.Adapted);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see V1 does:

  1. Enumerate properties using reflection.
  2. Add extended and adapted properties (excluding duplicates from 1.).

It seems if we request adapted properties we get reflection properties too. I guess there may be differences, but I do not know how it works in depth.

@SeeminglyScience @daxian-dbw Could you please help?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created Version type object, then add ETS noteproperty with Add-Member, then request properties with PSMemberInfoIntegratingCollection:

  • for Base I see only the note property,
  • for Extended I see only the note property,
  • for Adapted I see the note property and native properties.

My understanding is that is how PSObject wrapping works. PSObject is a property bag. So for Base just the ps object is considered. For Extend - only ETS properties as exclusively separate. For Adapted - adapters are involved including .Net Adapter which return native .Net members. Some adapters can be invovled (including XML, CIM/WMI,COM). Property priority is Extended -> Adapded -> Base.

V1 code add native .Net properties by reflection then enumerate Extended and Adapted ones with PSMemberInfoIntegratingCollection and add them only if the name is not already in list. So reflection properties win.
If we use only PSMemberInfoIntegratingCollection in V2 - Extended properties win. It is breaking change.
Perhaps it would be worthwhile to adhere to this native PowerShell behavior, but since historically this cmdlet works differently, it is not difficult for us to do the same by taking properties from the Json Default cache or by psObj.Properties.Match("*", PSMemberTypes.Property); (although it is more expensive).

@yotsuda yotsuda requested a review from a team as a code owner January 7, 2026 01:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CL-General Indicates that a PR should be marked as a general cmdlet change in the Change Log

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Parameter to ConvertTo-Json to ignore unsupported properties

2 participants