Powershell 7.2: ConvertFrom-Json - Date Handling

383 Views Asked by At

With Powershell 7.2 there seems to be a change in how a JSON is deserialized into an object in terms of dates -> instead of string it is now datetime. But I want to have the "old" behavior, i.e. that it is handled as string and NOT datetime.

How can I achieve that when using ConvertFrom-Json in Powershell 7.2 all dates are deserialized as string and not datetime?

EDIT:

$val = '{ "date":"2022-09-30T07:04:23.571+00:00" }' | ConvertFrom-Json
$val.date.GetType().FullName
4

There are 4 best solutions below

2
On BEST ANSWER

This is actually a known issue, see: #13598 Add a -DateKind parameter to ConvertFrom-Json to control how System.DateTime / System.DateTimeOffset values are constructed. Yet I think there is no easy solution for this. One thing you might do is just invoke (Windows) PowerShell. Which isn't currently straights forward as well therefore I have created a small wrapper to send and receive complex objects between PowerShell sessions (see also my #18460 Invoke-PowerShell purpose):

function Invoke-PowerShell ($Command) {
    $SerializeOutput = @"
         `$Output = $Command
         [System.Management.Automation.PSSerializer]::Serialize(`$Output)
"@
    $Bytes = [System.Text.Encoding]::Unicode.GetBytes($SerializeOutput)
    $EncodedCommand = [Convert]::ToBase64String($Bytes)
    $PSSerial = PowerShell -EncodedCommand $EncodedCommand
    [System.Management.Automation.PSSerializer]::Deserialize($PSSerial)
}

Usage:

Invoke-PowerShell { '{ "date":"2022-09-30T07:04:23.571+00:00" }' | ConvertFrom-Json }

date
----
2022-09-30T07:04:23.571+00:00

Update

As commented by mklement0, I clearly complicated the answer.

Calling via powershell.exe is a pragmatic workaround (albeit slow and Windows-only), but note that you don't need a helper function: if you pass a script block to powershell.exe (or pwsh.exe) from PowerShell, Based64 CLIXML-based serialization happens automatically behind the scenes: try powershell.exe -noprofile { $args | ConvertFrom-Json } -args '{ "date":"2022-09-30T07:04:23.571+00:00" }' For that reason, I don't think there's a need for an Invoke-PowerShell cmdlet.

$Json = '{ "date":"2022-09-30T07:04:23.571+00:00" }'
powershell.exe -noprofile { $args | ConvertFrom-Json } -args $Json

date
----
2022-09-30T07:04:23.571+00:00
0
On

Two additional ways to change the date format:

Get-Node

Using this Get-Node which is quiet similar to mklement0 recursive function:

$Data = ConvertFrom-Json $Json
$Data |Get-Node -Where { $_.Value -is [DateTime] } | ForEach-Object {
    $_.Value  = GetDate($_.Value) -Format 'yyyy-MM-ddTHH\:mm\:ss.fffzzz' -AsUTC
}
$Data

DIY

Or do-it-yourself and build your own Json deserializer:

function ConvertFrom-Json {
    [CmdletBinding()][OutputType([Object[]])] param(
        [Parameter(ValueFromPipeLine = $True, Mandatory = $True)][String]$InputObject,
        [String]$DateFormat = 'yyyy-MM-ddTHH\:mm\:ss.fffffffzzz', # Default: ISO 8601, https://www.newtonsoft.com/json/help/html/datesinjson.htm
        [Switch]$AsLocalTime,
        [Switch]$AsOrdered
    )
    function GetObject($JObject) {
        switch ($JObject.GetType().Name) {
            'JValue' {
                switch ($JObject.Type) {
                    'Boolean'  { $JObject.Value }
                    'Integer'  { 0 + $JObject.Value }                                                 # https://github.com/PowerShell/PowerShell/issues/14264
                    'Date'     { Get-Date $JObject.Value -Format $DateFormat -AsUTC:(!$AsLocalTime) } # https://github.com/PowerShell/PowerShell/issues/13598
                    Default    { "$($JObject.Value)" }
                }
            }
            'JArray' {
                ,@( $JObject.ForEach{ GetObject $_ } )
            }
            'JObject' {
                $Properties = [Ordered]@{}
                $JObject.ForEach{ $Properties[$_.Name] = GetObject $_.Value }
                if ($AsOrdered) { $Properties } else { [PSCustomObject]$Properties }                  # https://github.com/PowerShell/PowerShell/pull/17405
            }
        }
    }
    GetObject ([Newtonsoft.Json.Linq.JObject]::Parse($InputObject))
}

Usage:

ConvertFrom-Json $Json -DateFormat 'yyyy-MM-ddTHH\:mm\:ss.fffzzz' |ConvertTo-Json -Depth 9
0
On

Based on the input from @zett42 here my solution:

Assuming we know the regex pattern of the date used in the JSON I get the JSON as string, add a prefix so that ConvertFrom-Json does not convert dates to datetime but keeps it as string, convert it with ConvertFrom-Json to a PSCustomObject, do whatever I need to do on the object, serialize it back to a JSON string with ConvertTo-Json and then remove the prefix again.

[string]$json = '{ "date":"2022-09-30T07:04:23.571+00:00", "key1": "value1" }'

[string]$jsonWithDatePrefix = $json -replace '"(\d+-\d+.\d+T\d+:\d+:\d+\.\d+\+\d+:\d+)"', '"#$1"'

[pscustomobject]$jsonWithDatePrefixAsObject = $jsonWithDatePrefix | ConvertFrom-Json

$jsonWithDatePrefixAsObject.key1 = "value2"

[string]$updatedJsonString = $jsonWithDatePrefixAsObject | ConvertTo-Json

[string]$updatedJsonStringWithoutPrefix = $updatedJsonString -replace '"(#)(\d+-\d+.\d+T\d+:\d+:\d+\.\d+\+\d+:\d+)"', '"$2"'

Write-Host $updatedJsonStringWithoutPrefix
0
On
  • iRon's helpful answer provides a pragmatic solution via the Windows PowerShell CLI, powershell.exe, relying on the fact that ConvertFrom-Json there does not automatically transform ISO 8601-like timestamp strings to [datetime] instances.

    • Hopefully, the proposal in the GitHub issue he links to, #13598, will be implemented in the future, which would then simplify the solution to:

       # NOT YET IMPLEMENTED as of PowerShell 7.2.x
       '{ "date":"2022-09-30T07:04:23.571+00:00" }' |
         ConvertFrom-Json -DateTimeKind None
      
  • However, a powershell.exe workaround has two disadvantages: (a) it is slow (a separate PowerShell instance in a child process must be launched), and (b) it is Windows-only. The solution below is a generalization of your own approach that avoids these problems.


Here's a generalization of your own in-process approach:

  • It injects a NUL character ("`0") at the start of each string that matches the pattern of a timestamp - the assumption is that the input itself never contains such characters, which is fair to assume.

  • This, as in your approach, prevents ConvertFrom-Json from recognizing timestamp strings as such, and leaves them untouched.

  • The [pscustomobject] graph that ConvertFrom-Json outputs must then be post-processed in order to remove the injected NUL characters again.

    • This is achieved with a ForEach-Object call that contains a helper script block that recursively walks the object graph, which has the advantage that it works with JSON input whose timestamp strings may be at any level of the hierarchy (i.e. they may also be in properties of nested objects).

    • Note: The assumption is that the timestamp strings are only ever contained as property values in the input; more work would be needed if you wanted to handle input JSON such as '[ "2022-09-30T07:04:23.571+00:00" ]' too, where the strings are input objects themselves.

# Sample JSON.
$val = '{ "date":"2022-09-30T07:04:23.571+00:00" }'

$val -replace '"(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}")', "`"`0" |          #"
  ConvertFrom-Json |
  ForEach-Object {
    # Helper script block that walks the object graph
    $sb = {
      foreach ($o in $args[0]) { 
        if ($o -is [Array]) { # nested array -> recurse
          foreach ($el in $o) { & $sb $el } # recurse
        }
        elseif ($o -is [System.Management.Automation.PSCustomObject]) {
          foreach ($prop in $o.psobject.Properties) { 
            if ($prop.Value -is [Array]) {
              foreach ($o in $prop.Value) { & $sb $o } # nested array -> recurse
            }
            elseif ($prop.Value -is [System.Management.Automation.PSCustomObject]) { 
              & $sb $prop.Value # nested custom object -> recurse
            }
            elseif ($prop.Value -is [string] -and $prop.Value -match '^\0') { 
              $prop.Value = $prop.Value.Substring(1) # Remove the NUL again.
            }
          } 
        }
      }
    }
    # Call the helper script block with the input object.
    & $sb $_
    # Output the modified object.
    if ($_ -is [array]) {
      # Input object was array as a whole (implies use of -NoEnumerate), output as such.
      , $_ 
    } else {
      $_
    }
  }