What I'm trying to do is take any well-formed JSON file/object and search for a path inside it. If the path isn't found, move on. If it is found, update the value. Once updated, save the updated JSON to the original file.
The catch to this, is the well-formed JSON structure is not known ahead of time. It's possible I might be searching hundreds of .json files on a disk, so the files that don't match any of my search terms can just be ignored.
I'm struggling to wrap my head around how to solve this problem. Most of the examples out there don't have a JSON object with an array for one of the key values, or they don't access the properties dynamically when an array is involved.
This link: Powershell: How to Update/Replace data and values in Json and XML Object shows a (sort of)"real" JSON structure, but the accepted answer relies on knowing what the JSON structure is (the OP didn't ask for help with dynamic pathing).
This link: Set Value of Nested Object Property by Name in PowerShell has something very close, although when an array is in the mix, it doesn't work properly when setting.
Here's some example JSON to use with this problem, though again, the structure is not known before the script runs. I'm looping over a list of files on disk, and executing for each file.
$JSON = ConvertFrom-Json '{
"key1":"key 1 value",
"options":{
"outDir":"./app-dir",
"lib":[
"someLibrary",
"anotherLibrary"
],
"someObjects":[
{
"first":"I am first"
},
{
"second":"I am second"
}
]
}
}'
The string to search this json might look like the following:
$SearchString = 'options.someObjects.first'
Or perhaps, something non-existent like:
$SearchString = 'options.someObjects.foo'
Using the recursive function GetValue from the 2nd article works beautifully for getting (and much more elegant than what I was doing):
function GetValue($object, $key)
{
$p1,$p2 = $key.Split(".")
if($p2) { return GetValue -object $object.$p1 -key $p2 }
else { return $object.$p1 }
}
However, the function SetValue does not work with an array. It returns an error stating "The property 'first' can not be found on this object."
function SetValue($object, $key, $Value)
{
$p1,$p2 = $key.Split(".")
if($p2) { SetValue -object $object.$p1 -key $p2 -Value $Value }
else { $object.$p1 = $Value }
}
I am aware this is because $JSON.options.someObjects is an array, therefore to access the object with the "first" key, the path would be:
$JSON.options.someObjects[0].first
That's the problem I'm having. How do I dynamically iterate over all objects once it reaches a part of the path that needs iterating? That part of the path could be anywhere, or more levels down, etc...
It's strange that powershell will allow you to dynamically iterate through an array when getting the value, but not when trying to set it.
Here's a complete example which demonstrates the entire issue:
#Create JSON:
$JSON = ConvertFrom-Json '{
"key1":"key 1 value",
"options":{
"outDir":"./app-dir",
"lib":[
"someLibrary",
"anotherLibrary"
],
"someObjects":[
{
"first":"I am first"
},
{
"second":"I am second"
}
]
}
}'
$SearchPath = 'options.someObjects.first'
$NewValue = 'I am a new value'
function GetValue($object, $key)
{
$p1,$p2 = $key.Split(".")
if($p2) { GetValue -object $object.$p1 -key $p2 }
else { return $object.$p1 }
}
function SetValue($object, $key, $Value)
{
$p1,$p2 = $key.Split(".")
if($p2) { SetValue -object $object.$p1 -key $p2 -Value $Value }
else { return $object.$p1 = $Value }
}
GetValue -object $JSON -key $SearchPath
SetValue -object $JSON -key $SearchPath -Value $NewValue
I've been searching all kinds of different terms trying to arrive at a good solution for this problem, but so far, no luck. I'm fairly certain I'm not the 1st person to want to do this sort of thing, apologies if I missed the answer somewhere.
There are two issues with your SetValue script:
Returning the object
An Object ([Object]) vs an object array
([Object[]])
Return
You can't return an assignment like return $object.$p1 = $Value. The assignment itself returns nothing with will result in returning a $Null to caller.
Besides, if you return the $Object for each recursive call, you will need to void ($Null = SetValue -object...) it by each parent caller so that it is only returned by the top caller. but keep in mind that you are actually poking the $NewValue in the original ($JSON) object!. If you don't want that, you will need to figure out the top caller and only copy the $Object at the top level before the recursive call.
Object array
You not just dealing with properties containing single objects but each property might potentially contain a collection objects. In fact, the leaf property SomeObject is an example of this. Meaning that each object in the collection has its own unique set of properties (which could have the same property name as the sibling object):
$JSON = ConvertFrom-Json '{
"key1":"key 1 value",
"options":{
"outDir":"./app-dir",
"lib":[
"someLibrary",
"anotherLibrary"
],
"someObjects":[
{
"first":"I am first"
},
{
"first":"I am first too"
},
{
"second":"I am second"
}
]
}
}'
Note that you might actually encounter a object collection at every level of the Json object.
Since PSv3 you have a feature called Member Enumeration which lets you list these properties in ones, like: ([Object[]]$SomeObject).First but you can't just set (all) the concerned properties like this: ([Object[]]$SomeObject).First = $Value. (That is why your SetValue function doesn't work and your GetValue function does. Note that it
actually returns two items for the above "I am first too" Json example).
Answer
In other words, you will need to iterate through all the object collections on each level to set the concerned property:
function SetValue($object, $key, $Value)
{
$p1,$p2 = $key.Split(".",2)
if($p2) { $object | ?{$Null -ne $_.$p1} | %{SetValue -object $_.$p1 -key $p2 -Value $Value} }
else { $object | ?{$Null -ne $_.$p1} | %{$_.$p1 = $Value} }
}
SetValue -object $JSON -key $SearchPath -Value $NewValue
$Json | ConvertTo-Json -Depth 5
{
"key1": "key 1 value",
"options": {
"outDir": "./app-dir",
"lib": [
"someLibrary",
"anotherLibrary"
],
"someObjects": [
{
"first": "I am a new value"
},
{
"second": "I am second"
}
]
}
}
Related
I have an array of hashes that looks like this:
my $names = [
{
'name' => 'John'
},
{
'name' => '$teven'
},
{
'name' => 'Edgar'
}
];
I am trying to validate it in order to remove special characters, spaces, etc. however when I delete the key, I am left with {}. For example:
foreach (#{ $names}) {
if ($_->{name} =~ /[^\w+]/ ) {
print "Deleting $_->{name} due to non-standard characters" and delete $_->{name};
}
}
However afther that I am left with this result:
my $names = [
{
'name' => 'John'
},
{},
{
'name' => 'Edgar'
}
];
Instead of just:
my $names = [
{
'name' => 'John'
},
{
'name' => 'Edgar'
},
];
How can I remove the extra curly brackets when deleting the key?
p.s. to clarify as I see my question has been edited, the array of hashes is exactly as I previously posted it:
{
'name' => 'John'
}
{
'name' => '$teven'
}
{
'name' => 'Edgar'
}
Not with , and []; as I do a decode_json before that, so it's basically just the curly brackets that cause an issue, not the commas and square brackets.
Deleting keys from a hash does not delete the hash itself, even if the deletion leaves the hash empty. If you want to drop empty hashes, you will have to do so explicitly. One way to do this is to put the following after your loop:
#{ $names } = grep { keys %{ $_ } } #{ $names };
The grep built-in takes either a block (as above) or an expression, plus a list. It returns only those elements for which the block (or expression) is true. The keys built-in returns the keys of the hash. In scalar context it returns the number of keys, and so will be false only if the hash has no keys. I believe that in modern Perls this operation is optimized in Boolean context so that the entire list of keys is not generated.
Yes, this assumes the contents of #{ $names } are all hash reference, but your code makes the same assumption.
It is tempting to try to do something inside the loop, but actually removing array elements while iterating over an array is a recipe for trouble. The best I could come up with inside the loop is something like $_ = undef unless keys %{ $_ };, which replaces the empty hash reference with undef -- probably not what you want.
You are deleting from the hash when you want to remove the hash itself from the array.
When you want to filter an array, grep is your friend.
#$names =
grep {
if ( $_->{name} =~ /\W/ ) {
print "Deleting $_->{name} due to non-standard characters\n";
0
} else {
1
}
}
#$names;
It's a bit weird to have side-effects in a grep. If you don't like it, you could also use something like the following:
my #filtered;
for ( #$names ) {
if ( $_->{name} =~ /\W/ ) {
print "Deleting $_->{name} due to non-standard characters\n";
} else {
push #filtered, $_;
}
}
$names = \#filtered;
You could also use the less efficient #$names = #filtered; instead of $names = \#filtered; if you have other references to the array lying about.
Working with Graph API and Intune. I create hash table in my script and covert it to JSON which POST to GraphAPI.
But in one case during conversion I lose array. Because of this GraphAPI does not want to accept JSON and returns error.
Case when conversion is correct:
$TargetGroupIDs = #('111', '222')
$AppAssignment = #{
mobileAppAssignments = #{
}
}
$AppAssignment.mobileAppAssignments = foreach ($GroupID in $TargetGroupIDs) {
$Hash = [ordered]#{
"#odata.type" = "#microsoft.graph.mobileAppAssignment"
intent = "Required"
settings = $null
target = #{
"#odata.type" = "#microsoft.graph.groupAssignmentTarget"
groupId = "$GroupID"
}
}
write-output (New-Object -Typename PSObject -Property $hash)
}
$AppAssignment | ConvertTo-Json -Depth 50
Output:
{
"mobileAppAssignments": [
{
"#odata.type": "#microsoft.graph.mobileAppAssignment",
"intent": "Required",
"settings": null,
"target": {
"groupId": "111",
"#odata.type": "#microsoft.graph.groupAssignmentTarget"
}
},
{
"#odata.type": "#microsoft.graph.mobileAppAssignment",
"intent": "Required",
"settings": null,
"target": {
"groupId": "222",
"#odata.type": "#microsoft.graph.groupAssignmentTarget"
}
}
]
}
But when I have one element in $TargetGroupIDs conversion is not correct:
$TargetGroupIDs = #('111')
$AppAssignment = #{
mobileAppAssignments = #{
}
}
$AppAssignment.mobileAppAssignments = foreach ($GroupID in $TargetGroupIDs) {
$Hash = [ordered]#{
"#odata.type" = "#microsoft.graph.mobileAppAssignment"
intent = "Required"
settings = $null
target = #{
"#odata.type" = "#microsoft.graph.groupAssignmentTarget"
groupId = "$GroupID"
}
}
write-output (New-Object -Typename PSObject -Property $hash)
}
$AppAssignment | ConvertTo-Json -Depth 50
Output:
{
"mobileAppAssignments": {
"#odata.type": "#microsoft.graph.mobileAppAssignment",
"intent": "Required",
"settings": null,
"target": {
"groupId": "111",
"#odata.type": "#microsoft.graph.groupAssignmentTarget"
}
}
}
Please note difference in brackets after mobileAppAssignments. In first case [], but in second case {}.
Could someone tell what I am missing in second case?
Theo and Santiago Squarzon have provided the crucial hint in the comments, but let me spell it out:
To ensure that the output from your foreach statement is an array, enclose it in #(), the array-subexpression operator:
$AppAssignment.mobileAppAssignments = #(
foreach ($GroupID in $TargetGroupIDs) {
[pscustomobject] #{
"#odata.type" = "#microsoft.graph.mobileAppAssignment"
intent = "Required"
settings = $null
target = #{
"#odata.type" = "#microsoft.graph.groupAssignmentTarget"
groupId = "$GroupID"
}
}
}
)
Also note that simplified syntax custom-object literal syntax ([pscustomobject] #{ ... }).
#(...) ensures that an array (of type [object[]]) is returned, irrespective of how many objects, if any, the foreach statement outputs.
Without #(...), the data type of the collected output depends on the number of output objects produced by a statement or command:
If there is no output object, the special "AutomationNull" value is returned, which signifies the absence of output; this special value behaves like an empty collection in enumeration contexts, notably the pipeline, and like $null in expression contexts - see this answer for more information; in the context at hand, it would be converted to a null JSON value.
If there is one output object, it is collected as-is, as itself - this is what you saw.
Only with two or more output objects do you get an array (of type [object[]]).
Note: The behavior above follows from the streaming behavior of the PowerShell pipeline / its success output stream: object are emitted one by one, and needing to deal with multiple objects only comes into play if there's a need to collect output objects: see this answer for more information.
For context, I am attempting to create a cmdlet that would allow for single value substitutions on arbitrary Json files, for use in some pipelines. I've managed to get this working for non-array-containing Json.
A representative bit of Json:
{"test": {
"env": "dev",
"concept": "abstraction",
"array": [
{"id":1, "name":"first"},
{"id":2, "name":"second"}
]
}
}
I want to be able to replace values by providing a function with a path like test.array[1].name and a value.
After using ConvertFrom-Json on the Json above, I attempt to use the following function (based on this answer) to replace second with third:
function SetValue($object, $key, $value) {
$p1, $p2 = $key.Split(".")
$a = $p1 | Select-String -Pattern '\[(\d{1,3})\]'
if ($a.Matches.Success) {
$index = $a.Matches.Groups[1].Value
$p1 = ($p1 | Select-String -Pattern '(\w*)\[').Matches.Groups[1].Value
if ($p2.length -gt 0) { SetValue -object $object.$p1[$index] -key $p2 -value $value }
else { $object.$p1[$index] = $value }
}
else {
if ($p2.length -gt 0) { SetValue -object $object.$p1 -key $p2 -value $value }
else {
Write-Host $object.$p1
$object.$p1 = $value
}
}
}
$content = SetValue -object $content -key "test.array[1].name" -rep "third"
Unfortunately this results in the following:
{ "test": {
"env": "dev",
"concept": "abstraction",
"array": [
"#{id=1; name=first}",
"#{id=2; name=third}"
]
}
}
If the values in the array aren't objects the code works fine as presented, it's only when we get to objects within arrays that this output happens.
What would be a way to ensure that the returned Json contains an array that is more in line with the input?
Edit: please note that the actual cause of the issue lay in not setting the -Depth property of ConvertTo-Json to 3 or greater. Doing so restored the resulting Json to the expected format. The accepted answer was still helpful in investigating the cause.
While Invoke-Expression (iex) should generally be avoided, there are exceptional cases where it offers the simplest solution.
$fromJson = #'
{
"test": {
"env": "dev",
"concept": "abstraction",
"array": [
{"id":1, "name":"first"},
{"id":2, "name":"second"}
]
}
}
'# | ConvertFrom-Json
$nestedPropertyAccessor = 'test.array[1].name'
$newValue = 'third'
Invoke-Expression "`$fromJson.$nestedPropertyAccessor = `"$newValue`""
Important:
Be sure that you either fully control or implicitly trust the content of the $nestedPropertyAccessor and $newValue variables, to prevent inadvertent or malicious execution of injected commands.
On re-conversion to JSON, be sure to pass a high-enough -Depth argument to ConvertTo-Json; with the sample JSON, at least -Depth 3 is required - see this post.
I'm trying to pack my data into objects before displaying them with ConvertTo-Json. The test case below shows perfectly how I'm dealing with data and what problem occurs:
$array = #("a","b","c")
$data = #{"sub" = #{"sub-sub" = $array}}
$output = #{"root" = $data}
ConvertTo-Json -InputObject $data
ConvertTo-Json -InputObject $output
Output (formatted by hand for clarity):
{ "sub": { "sub-sub": [ "a", "b", "c" ] }}
{ "root": { "sub": { "sub-sub": "a b c" } }}
Is there any way to assign $data to $output without this weird implicit casting?
As mentioned in the comments, ConvertTo-Json will try to flatten the object structure beyond a maximum nesting level, or depth, by converting whatever object it finds beyond that depth to a string.
The default depth is 2, but you can specify that it should go deeper with the Depth parameter:
PS C:\> #{root=#{level1=#{level2=#("level3-1","level3-2")}}}|ConvertTo-Json
{
"root": {
"level1": {
"level2": "level3-1 level3-2"
}
}
}
PS C:\> #{root=#{level1=#{level2=#("level3-1","level3-2")}}}|ConvertTo-Json -Depth 3
{
"root": {
"level1": {
"level2": [
"level3-1",
"level3-2"
]
}
}
}
I'm trying to pack my data into objects before displaying them with ConvertTo-Json. The test case below shows perfectly how I'm dealing with data and what problem occurs:
$array = #("a","b","c")
$data = #{"sub" = #{"sub-sub" = $array}}
$output = #{"root" = $data}
ConvertTo-Json -InputObject $data
ConvertTo-Json -InputObject $output
Output (formatted by hand for clarity):
{ "sub": { "sub-sub": [ "a", "b", "c" ] }}
{ "root": { "sub": { "sub-sub": "a b c" } }}
Is there any way to assign $data to $output without this weird implicit casting?
As mentioned in the comments, ConvertTo-Json will try to flatten the object structure beyond a maximum nesting level, or depth, by converting whatever object it finds beyond that depth to a string.
The default depth is 2, but you can specify that it should go deeper with the Depth parameter:
PS C:\> #{root=#{level1=#{level2=#("level3-1","level3-2")}}}|ConvertTo-Json
{
"root": {
"level1": {
"level2": "level3-1 level3-2"
}
}
}
PS C:\> #{root=#{level1=#{level2=#("level3-1","level3-2")}}}|ConvertTo-Json -Depth 3
{
"root": {
"level1": {
"level2": [
"level3-1",
"level3-2"
]
}
}
}