Handling multiple CSVs in Powershell efficiently - powershell

I am retrieving two CSVs from an API, one called students.csv similar to:
StudentNo,PreferredFirstnames,PreferredSurname,UPN
111, john, smith, john#email.com
222, jane, doe, jane#email.com
one called rooms.csv:
roomName, roomNo, students
room1, 1, {#{StudentNo=111; StudentName=john smith; StartDate=2018-01-01T00:00:00; EndDate=2018-07-06T00:00:00},....
room2, 2,{#{StudentNo=222; StudentName=jane doe; StartDate=2018-01-01T00:00:00; EndDate=2018-07-06T00:00:00},...
The third column in rooms.csv is an array as provided by the API
What is the best way to consolidate the two into
StudentNo,PreferredFirstnames,PreferredSurname,UPN, roomName
111, john, smith, john#email.com, room1
222, jane, doe, jane#email.com, room2
Im thinking something like...
$rooms = Import-Csv rooms.csv
$students = Import-Csv students.csv
$combined = $students | select-object StudentNo,PreferredSurname,PreferredFirstnames,UPN,
#{Name="roomName";Expression={ ForEach ($r in $rooms) {
if ($r.Students.StudentNo.Contains($_.StudentNo) -eq "True")
{return $r.roomName}}}}
This works, but is the foreach the right way to go am i mixing things up or is there a more efficient way???
--- Original Post ---
With all of this information I need to compare the student data and update AzureAD and then compile a list of data including first name, last name, upn, room and others that are retrieved from AzureAD.
My issue is "efficiency". I have code that mostly works but it takes hours to run. Currently I am looping through students.csv and then for each student looping through rooms.csv to find the room they're in, and obviously waiting for multiple api calls in-between all this.
What is the most efficient way to find the room for each student? Is importing the CSV as a custom PSObject comparable to using hash tables?

I was able to get your proposed code to work but it requires some tweaks to the code and data:
There must be some additional step where you are deserializing the students column of rooms.csv to a collection of objects. It appears to be a ScriptBlock that evaluates to an array of HashTables, but some changes to the CSV input are still needed:
The StartDate and EndDate properties need to be quoted and cast to [DateTime].
At least for rooms that contain multiple students, the value must be quoted so Import-Csv doesn't interpret the , separating array elements as an additional column.
The downside of using CSV as an intermediate format is the original property types are lost; everything becomes a [String] upon import. Sometimes it's desirable to cast back to the original type for efficiency purposes, and sometimes it's absolutely necessary in order for certain operations to work. You could cast those properties every time you use them, but I prefer to cast them once immediately after import.
With those changes rooms.csv becomes...
roomName, roomNo, students
room1, 1, "{#{StudentNo=111; StudentName='john smith'; StartDate=[DateTime] '2018-01-01T00:00:00'; EndDate=[DateTime] '2018-07-06T00:00:00'}}"
room2, 2, "{#{StudentNo=222; StudentName='jane doe'; StartDate=[DateTime] '2018-01-01T00:00:00'; EndDate=[DateTime] '2018-07-06T00:00:00'}}"
...and the script becomes...
# Replace the [String] property "students" with an array of [HashTable] property "Students"
$rooms = Import-Csv rooms.csv `
| Select-Object `
-ExcludeProperty 'students' `
-Property '*', #{
Name = 'Students'
Expression = {
$studentsText = $_.students
$studentsScriptBlock = Invoke-Expression -Command $studentsText
$studentsArray = #(& $studentsScriptBlock)
return $studentsArray
}
}
# Replace the [String] property "StudentNo" with an [Int32] property of the same name
$students = Import-Csv students.csv `
| Select-Object `
-ExcludeProperty 'StudentNo' `
-Property '*', #{
Name = 'StudentNo'
Expression = { [Int32] $_.StudentNo }
}
$combined = $students `
| Select-Object -Property `
'StudentNo', `
'PreferredSurname', `
'PreferredFirstnames', `
'UPN', `
#{
Name = "roomName";
Expression = {
foreach ($r in $rooms)
{
if ($r.Students.StudentNo -contains $_.StudentNo)
{
return $r.roomName
}
}
#TODO: Return text indicating room not found?
}
}
The reason this can be slow is because you are performing a linear search - two of them, in fact - for every student object; first through the collection of rooms (foreach), then through the collection of students in each room (-contains). This can quickly turn into a lot of iterations and equality comparisons because in every room to which the current student is not assigned you are iterating the entire collection of that room's students, on and on until you do find the room for that student.
One easy optimization you can make when performing a linear search is to sort the items you're searching (in this case, the Students property will be ordered by the StudentNo property of each student)...
# Replace the [String] property "students" with an array of [HashTable] property "Students"
$rooms = Import-Csv rooms.csv `
| Select-Object `
-ExcludeProperty 'students' `
-Property '*', #{
Name = 'Students'
Expression = {
$studentsText = $_.students
$studentsScriptBlock = Invoke-Expression -Command $studentsText
$studentsArray = #(& $studentsScriptBlock) `
| Sort-Object -Property #{ Expression = { $_.StudentNo } }
return $studentsArray
}
}
...and then when you're searching that same collection if you come across an item that is greater than the one you're searching for you know the remainder of the collection can't possibly contain what you're searching for and you can immediately abort the search...
#{
Name = "roomName";
Expression = {
foreach ($r in $rooms)
{
# Requires $room.Students to be sorted by StudentNo
foreach ($roomStudentNo in $r.Students.StudentNo)
{
if ($roomStudentNo -eq $_.StudentNo)
{
# Return the matched room name and stop searching this and further rooms
return $r.roomName
}
elseif ($roomStudentNo -gt $_.StudentNo)
{
# Stop searching this room
break
}
# $roomStudentNo is less than $_.StudentNo; keep searching this room
}
}
#TODO: Return text indicating room not found?
}
}
Better yet, with a sorted collection you can also perform a binary search, which is faster than a linear search*. The Array class already provides a BinarySearch static method, so we can accomplish this in less code, too...
#{
Name = "roomName";
Expression = {
foreach ($r in $rooms)
{
# Requires $room.Students to be sorted by StudentNo
if ([Array]::BinarySearch($r.Students.StudentNo, $_.StudentNo) -ge 0)
{
return $r.roomName
}
}
#TODO: Return text indicating room not found?
}
}
The way I would approach this problem, however, is to use a [HashTable] mapping a StudentNo to a room. There is a little preprocessing required to build the [HashTable] but this will provide constant-time lookups when retrieving the room for a student.
function GetRoomsByStudentNoTable()
{
$table = #{ }
foreach ($room in $rooms)
{
foreach ($student in $room.Students)
{
#NOTE: It is assumed each student belongs to at most one room
$table[$student.StudentNo] = $room
}
}
return $table
}
# Replace the [String] property "students" with an array of [HashTable] property "Students"
$rooms = Import-Csv rooms.csv `
| Select-Object `
-ExcludeProperty 'students' `
-Property '*', #{
Name = 'Students'
Expression = {
$studentsText = $_.students
$studentsScriptBlock = Invoke-Expression -Command $studentsText
$studentsArray = #(& $studentsScriptBlock)
return $studentsArray
}
}
# Replace the [String] property "StudentNo" with an [Int32] property of the same name
$students = Import-Csv students.csv `
| Select-Object `
-ExcludeProperty 'StudentNo' `
-Property '*', #{
Name = 'StudentNo'
Expression = { [Int32] $_.StudentNo }
}
$roomsByStudentNo = GetRoomsByStudentNoTable
$combined = $students `
| Select-Object -Property `
'StudentNo', `
'PreferredSurname', `
'PreferredFirstnames', `
'UPN', `
#{
Name = "roomName";
Expression = {
$room = $roomsByStudentNo[$_.StudentNo]
if ($room -ne $null)
{
return $room.roomName
}
#TODO: Return text indicating room not found?
}
}
You can ameliorate the hit of building $roomsByStudentNo by doing so at the same time as importing rooms.csv...
# Replace the [String] property "students" with an array of [HashTable] property "Students"
$rooms = Import-Csv rooms.csv `
| Select-Object `
-ExcludeProperty 'students' `
-Property '*', #{
Name = 'Students'
Expression = {
$studentsText = $_.students
$studentsScriptBlock = Invoke-Expression -Command $studentsText
$studentsArray = #(& $studentsScriptBlock)
return $studentsArray
}
} `
| ForEach-Object -Begin {
$roomsByStudentNo = #{ }
} -Process {
foreach ($student in $_.Students)
{
#NOTE: It is assumed each student belongs to at most one room
$roomsByStudentNo[$student.StudentNo] = $_
}
return $_
}
*Except for on small arrays

Related

How to fix System Object value in PowerShell

I'm Importing a CSV file and reading a column that look like this
Exchange Mailboxes
Include:[john.doe#outlook.com]
Include:[david.smith#outlook.com]
Include:[kevin.love#outlook.com]
I use Get-EXOMailbox to get their DisplayName and Id. After that I'm trying to pass it in my New-Object like below so that I can export it. The problem I have is when I look at my Excel file, it showing System.Object[] on every row instead of showing each actual DisplayName and Id.
Any help on how to display it correctly would be really appreciated.
$result = Import-Csv "C:\AuditLogSearch\Dis\Modified-Audit-Log-Records.csv" |
Where-Object { -join $_.psobject.Properties.Value } |
ForEach-Object {
$exoMailbox = ($_.'Exchange Mailboxes' -split '[][]')[1]
$exoUser = Get-EXOMailbox -Filter "PrimarySmtpAddress -eq '$exoMailbox'"
# Construct and output a custom object with the properties of interest.
[pscustomobject] #{
UserName = $exoUser.DisplayName
UserId = $exoUser.Identity
}
}
New-Object PsObject -Property #{
'Searched User' = $result.UserName //I'm trying to pass here
'SharePoint URL' = $spUrl
'Searched User GMID' = $result.UserId //and here
'Site Owner' = $spositeOwner
User = $u.User
"Result Status" = $u."Result Status"
"Date & Time" = $u."Date & Time"
"Search Conditions" = $u."Search Conditions"
"SharePoint Sites" = $u."SharePoint Sites"
"Exchange Public Folders" = $u."Exchange Public Folders"
"Exchange Mailboxes" = $u."Exchange Mailboxes".Split([char[]]#('[', ']'))[1]
"Case Name" = $u."Case Name"
"Search Criteria" = $u."Search Criteria"
"Record Type" = $u."Record Type"
"Hold Name" = $u."Hold Name".Split(('\'))[1]
"Activity" = if ($null -ne ($importData | where-object { $_.Name -eq $u."Activity" }).Value) { ($importData | where-object { $_.Name -eq $u."Activity" }).Value }
else { $u."Activity" }
} | Select-object -Property User, "Date & Time", "Case Name", "Hold Name", "Record Type", "Activity" , "Searched User", "Searched User GMID", "SharePoint URL", "Exchange Mailboxes", "Exchange Public Folders" , "Search Criteria", "Result Status"
}
$xlsx = $result | Export-Excel #params
$ws = $xlsx.Workbook.Worksheets[$params.Worksheetname]
$ws.Dimension.Columns
$ws.Column(1).Width = 20
$ws.Column(2).Width = 20
$ws.Column(3).Width = 15
$ws.Column(4).Width = 15
$ws.Column(5).Width = 15
$ws.Column(6).Width = 160
$ws.View.ShowGridLines = $false
Close-ExcelPackage $xlsx
$result is an array of objects, containing an object for each non-empty row in your input CSV; thus, adding values such as $result.UserName to the properties of the object you're creating with New-Object will be arrays too, which explains your symptom (it seems that Export-Excel, like Export-Csv doesn't meaningfully support array-valued properties and simply uses their type name, System.Object[] during export).
It sounds like the easiest solution is to add the additional properties directly in the ForEach-Object call, to the individual objects being constructed and output via the existing [pscustomobject] literal ([pscustomobject] #{ ... }):
$result =
Import-Csv "C:\AuditLogSearch\Dis\Modified-Audit-Log-Records.csv" |
Where-Object { -join $_.psobject.Properties.Value } | # only non-empty rows
ForEach-Object {
$exoMailbox = ($_.'Exchange Mailboxes' -split '[][]')[1]
$exoUser = Get-EXOMailbox -Filter "PrimarySmtpAddress -eq '$exoMailbox'"
# Construct and output a custom object with the properties of interest.
[pscustomobject] #{
UserName = $exoUser.DisplayName
UserId = $exoUser.Identity
# === Add the additional properties here:
'Searched User' = $exoUser.UserName
'SharePoint URL' = $spUrl
'Searched User GMID' = $exoUser.UserId
'Site Owner' = $spositeOwner
# ...
}
}
Note:
The above shows only some of the properties from your question; add as needed (it is unclear where $u comes from in some of them.
Using a custom-object literal ([pscustomobject] #{ ... }) is not only easier and more efficient than a New-Object PSObject -Property #{ ... }[1] call, unlike the latter it implicitly preserves the definition order of the properties, so that there's no need for an additional Select-Object call that ensures the desired ordering of the properties.
[1] Perhaps surprisingly, PSObject ([psobject]) and PSCustomObject ([pscustomobject]) refer to the same type, namely System.Management.Automation.PSObject, despite the existence of a separate System.Management.Automation.PSCustomObject, which custom-objects instances self-report as (([pscustomobject] #{}).GetType().FullName) - see GitHub issue #4344 for background information.

Possible to pull info from AdditionalProperties dictionary with Microsoft Graph PowerShell cmdlets?

I am trying to use PowerShell Graph cmdlets instead of the Azure AD module cmdlets. With the Azure AD module, I can do this:
# This is what I want:
get-azureadgroupmember -objectid $GroupID | select-object -property displayname, `
mail, userprincipalname, objectid
DisplayName Mail UserPrincipalName ObjectId
----------- ---- ----------------- --------
John Smith John.Smith#example.org jsmith#example.org 4bae8291-6ec3-192b-32ce-dd21869ef784
(...)
# All of these properties are directly accessible in the returned objects:
$res = get-azureadgroupmember -objectid $GroupID
$res[0] | fl -prop *
# Shows long list of directly accessible properties
I'm trying to figure out the equivalent with PowerShell Graph:
$res = get-mggroupmember -groupid $GroupID
$res[0] | fl -prop *
# Only properties are DeletedDateTime, Id, and AdditionalProperties
# Want to do something like this, but it doesn't work:
get-mggroupmember -groupid $GroupID | select-object -property id, `
additionalproperties['displayName'], additionalproperties['mail'], `
additionalproperties['userPrincipalName']
# This works, but is there a better option???
get-mggroupmember -groupid $GroupID | foreach-object { `
"{0},{1},{2},{3}" -f $_.id, $_.additionalproperties['displayName'], `
$_.additionalproperties['mail'], $_.additionalproperties['userPrincipalName']
}
AdditionalProperties is a dictionary (IDictionary) which contains displayname, mail, and userprincipalname. My thought is there is probably a better way to do this or to get at the information.
There are a few interesting parameters in get-mggroupmember that I'm not clear on including "-expandproperty" and "-property". I've tried playing around with these but haven't had any luck. I'm wondering if there's a way to use these to do what I want.
Suggestions?
Given the following $object, 3 properties and one of them AdditionalProperties is a Dictionary<TKey,TValue>:
$dict = [Collections.Generic.Dictionary[object, object]]::new()
$dict.Add('displayName', 'placeholder')
$dict.Add('mail', 'placeholder')
$dict.Add('userPrincipalName', 'placeholder')
$object = [pscustomobject]#{
DeletedDateTime = 'placeholder'
Id = 'placeholder'
AdditionalProperties = $dict
}
Supposing from this object you're interested in Id, displayName and mail, you could use Select-Object with calculated properties:
$object | Select-Object #(
'Id'
#{
Name = 'displayName'
Expression = { $_.additionalProperties['displayName'] }
}
#{
Name = 'mail'
Expression = { $_.additionalProperties['mail'] }
}
)
However this gets messy as soon as you need to pick more property values from the objects, PSCustomObject with a loop comes in handy in this case:
$object | ForEach-Object {
[pscustomobject]#{
Id = $_.Id
displayName = $_.additionalProperties['displayName']
mail = $_.additionalProperties['mail']
}
}
Both alternatives would output the same "flattened" object that can be converted to Csv without any issue:
As Object
Id displayName mail
-- ----------- ----
placeholder placeholder placeholder
As Csv
"Id","displayName","mail"
"placeholder","placeholder","placeholder"
In that sense, you could construct an array of objects using one of the above techniques, for example:
Get-MgGroupMember -GroupId $GroupID | ForEach-Object {
[pscustomobject]#{
Id = $_.id
displayName = $_.additionalproperties['displayName']
mail = $_.additionalproperties['mail']
userPrincipalName = $_.additionalproperties['userPrincipalName']
}
}
If you're looking for a programmatical way to flatten the object, you can start by using this example, however it's important to note that this can only handle an object which's property is nested only once, in other words, it can't handle recursion:
$newObject = [ordered]#{}
foreach($property in $object.PSObject.Properties) {
if($property.Value -is [Collections.IDictionary]) {
foreach($addproperty in $property.Value.GetEnumerator()) {
$newObject[$addproperty.Key] = $addproperty.Value
}
continue
}
$newObject[$property.Name] = $property.Value
}
[pscustomobject] $newObject
The output from this would become a flattened object like this, which also, can be converted to Csv without any issue:
DeletedDateTime : placeholder
Id : placeholder
displayName : placeholder
mail : placeholder
userPrincipalName : placeholder
It's also worth noting that above example is not handling possible key collision, if there are 2 or more properties with the same name, one would override the others.
Bonus function that should work with the objects returned by the cmdlets from Graph, AzureAD and Az Modules. This function can be useful to flatten their Dictionary`2 property. It only looks one level deep if the property value implements IDictionary so don't expect it to flatten any object. For the given example should work well.
function Select-GraphObject {
[CmdletBinding()]
param(
[parameter(ValueFromPipeline, DontShow)]
[object] $InputObject,
[parameter(Position = 0)]
[string[]] $Properties = '*'
)
begin {
$firstObject = $true
$toSelect = [Collections.Generic.List[object]]::new()
}
process {
if($firstObject) {
foreach($property in $InputObject.PSObject.Properties) {
foreach($item in $Properties) {
if($property.Value -is [Collections.IDictionary]) {
foreach($key in $property.Value.PSBase.Keys) {
if($key -like $item -and $key -notin $toSelect.Name) {
$toSelect.Add(#{
$key = { $_.($property.Name)[$key] }
})
}
}
continue
}
if($property.Name -like $item -and $property.Name -notin $toSelect) {
$toSelect.Add($property.Name)
}
}
}
$firstObject = $false
}
$out = [ordered]#{}
foreach($item in $toSelect) {
if($item -isnot [hashtable]) {
$out[$item] = $InputObject.$item
continue
}
$enum = $item.GetEnumerator()
if($enum.MoveNext()) {
$out[$enum.Current.Key] = $InputObject | & $enum.Current.Value
}
}
[pscustomobject] $out
}
}
Using copies of the $object from above examples, if using the default value of -Properties, the example objects would be flattened:
PS /> $object, $object, $object | Select-GraphObject
DeletedDateTime Id displayName mail userPrincipalName
--------------- -- ----------- ---- -----------------
placeholder placeholder placeholder placeholder placeholder
placeholder placeholder placeholder placeholder placeholder
placeholder placeholder placeholder placeholder placeholder
Or we can filter for specific properties, even Keys from the AdditionalProperties Property:
PS /> $object, $object, $object | Select-GraphObject Id, disp*, user*
Id displayName userPrincipalName
-- ----------- -----------------
placeholder placeholder placeholder
placeholder placeholder placeholder
placeholder placeholder placeholder

matching data across two arrays and combining with additional data in array

The Goal
See if $SP.ip is in $NLIP.IpRanges and if it is, add $NLIP.IpRanges and $NLIP.DisplayName to the $SP array or all into a new array.
The Arrays
Array 1 is $SP, it's a CSV import and has the properties 'name' and 'ip', it looks like this:
name: bob
ip: 1.9.8.2
Array 2 is $NLIP and has the relevant properties 'IpRanges' and 'DisplayName'. It's fetched from: $NLIP = Get-AzureADMSNamedLocationPolicy | where-object {$_.OdataType -eq "#microsoft.graph.ipNamedLocation"}, it looks like this:
DisplayName : Named Location 1
IpRanges : {class IpRange {
CidrAddress: 16.29.28.9/28 #fictitious CIDR
}
, class IpRange {
CidrAddress: 1.9.8.3/28 #fictitious CIDR
}
}
The Code / the problem
I'm using IPInRange.ps1 function from https://github.com/omniomi/PSMailTools to find if the IP is in the range. It works like so:
> IPInRange 1.9.8.2 1.9.8.3/28
True
I also worked out that $NLTP.IpRanges.split() | Where-Object ($_ -like "*/*"} can return all the ranges, but $NLIP | Where-Object {$_.IpRanges.split() -like "*/*"} doesn't. I would naturally use the second to keep the variable in the pipe to return the DisplayName. So I'm struggling on how to pull the individual ranges out in such a way that I can then add the 'IpRange' and 'DisplayName' to an array.
Also, maybe it's because I haven't worked out the above issue, but I'm struggling to think how I would iterate through both arrays and combine them into one. I know I would probably enter into a foreach ($item in $SP) and create a temporary array, but after that it's getting hazy.
The result
What I'm hoping to have in the end is:
name: bob
ip: 1.9.8.2
IpRange: 1.9.8.3/28 #fictitious CIDR
DisplayName: Named Location 1
thanks in advance.
I believe this will work for you if I understood the NLIP construct correctly.
We will loop through all the SP objects and see if we can find any NLIP that match the IP range using the IPinRange function you linked. We will then add the 2 properties you want to the SP object if matched and finally pass thru to the pipeline or you can append | export-csv -path YourPath to the end if you would like to send to a csv file
$SP | ForEach-Object {
$target = $_
$matched = $NLIP | ForEach-Object {
$item = $_
# Using where to single out matching range using IPinRange function
$_.IpRanges.Where({ IPInRange -IPAddress $target.ip -Range $_.CidrAddress }) |
ForEach-Object {
# for matching range output custom object containing the displayname and iprange
[PSCustomObject]#{
DisplayName = $item.DisplayName
IpRange = $_.CidrAddress
}
}
}
# add the 2 properties (DisplayName and IpRange) from the match to the original $SP
# object and then pass thru
$target | Add-Member -NotePropertyName DisplayName -NotePropertyValue $matched.DisplayName
$target | Add-Member -NotePropertyName IpRange -NotePropertyValue $matched.IpRange -PassThru
}
By the way, this is how I envisioned the NLIP objects and what I tested with
$NLIP = #(
[pscustomobject]#{
DisplayName = 'Named location 1'
IpRanges = #(
[pscustomobject]#{
CidrAddress = '16.29.28.9/28'
},
[pscustomobject]#{
CidrAddress = '1.9.8.3/28'
}
)
},
[pscustomobject]#{
DisplayName = 'Named location 2'
IpRanges = #(
[pscustomobject]#{
CidrAddress = '16.29.28.25/28'
},
[pscustomobject]#{
CidrAddress = '1.9.8.25/28'
}
)
}
)
Let's to shed some lights in the hazy darkness by first creating a Minimal, Reproducible Example (mcve):
$SP = ConvertFrom-Csv #'
IP, Name
1.9.8.2, BOB
10.10.10.10, Apple
16.29.28.27, Pear
16.30.29.28, Banana
'#
$NLIP = ConvertFrom-Csv #'
IPRange, SubNet
16.29.28.9/28, NetA
1.9.8.3/28, NetB
'#
To tackle this, you need two loops where the second loop is inside the first loop. For the outer loop you might use the ForEach-Object cmdlet which lets you stream each object and with that actually use less memory (assuming that you import the data from a file and eventually export it to a new file). Within the inner loop you might than cross link each IP address with the IPRange using the function you refer to and in case the condition is true create a new PSCustomObject:
$SP |ForEach-Object { # | Import-Csv .\SP.csv |ForEach-Object { ...
ForEach($SubNet in $NLIP) {
if (IPInRange $_.IP $SubNet.IPRange) {
[PSCustomObject]#{
IP = $_.IP
Name = $_.Name
IPRange = $SubNet.IPRange
SubNet = $SubNet.SubNet
}
}
}
} # | Export-Csv .\Output.csv
Which results in:
IP Name IPRange SubNet
-- ---- ------- ------
1.9.8.2 BOB 1.9.8.3/28 NetB
16.29.28.27 Pear 16.29.28.9/8 NetA
16.30.29.28 Banana 16.29.28.9/8 NetA
But as you are considering 3rd party scripts anyways, you might as well use this Join-Object script/Join-Object Module (see also: In Powershell, what's the best way to join two tables into one?):
$SP |Join $NLIP -Using { IPInRange $Left.IP $Right.IPRange }
Which gives the same results.

Looping through and appending a hastable instead of adding to keys

I'm looping through a CSV file and using ForEach-Object loop to grab info to attempt to update in_stock status on Woocommerce, what ends up happening is the woocommerce only see's one entry. I'm not a programmer, I'm still learning PowerShell and for the life of me I just can't understand the logic of for loops and it's output properly. I know it reads the entries in the CSV, but I think it's just overwriting the previous entry.
Another issue I'm having is properly setting in_stock values as true and false for each object respectively, if one is true then all false entries are also set as true. I can't seem to figure out how to assign true | false correctly.
I've been looking up PowerShell using the MS docs on it and how to append hashtables but I'm still not finding the answers or examples that will point me in the right direction. I've gone so far as to purchase PowerShell tutorials offsite and still haven't found a way to do this properly.
$website = "https://www.mywebsite.com"
$params += #{
type= #();
name = #();
SKU = #();
catalog_visibility = #();
regular_price = #();
in_stock = #();
categories = #();
}
$csv = Import-Csv C:\test\test-upload.csv
$csv | Select-Object -Property Type, SKU, Name, 'Visibility in catalog',
'Tax status', 'In stock?', Stock, 'Backorders allowed?', 'Allow customer
reviews?', 'Regular price', Categories | ForEach-Object{
$params.type += $_.type
$params.SKU += $_.SKU
$params.name += $_.name
$params.catalog_visibility += $_.'Visibility in catalog'
$params.categories += $_.Categories
$params.regular_price += $_.'Regular price'
$params.in_stock += $_.'In stock?'
if ($params.in_stock = 0) {$params.in_stock -replace 0, $false}
elseif($params.in_stock = 1) {$params.in_stock -replace 1, $true}
}
foreach($key in $params.keys){
Write-Output $params[$key]
}
I'm looking to get something like this
{
"name": "part 1",
"type": "simple",
"SKU": "0001",
"regular_price": "21.99",
"in_stock: false",
"categories: category 1",
"catalog_visibility": "hidden",
},
{
"name": "part 2",
"type": "simple",
"SKU": "0002",
"regular_price": "11.99",
"in_stock: true",
"categories: category 2",
"catalog_visibility": "hidden",
}
and what I am actually getting is
{
"name": "part 1 part 2",
"type": "simple simple ",
"SKU": "0001 0002",
"regular_price": "21.99 11.99",
"in_stock: true true",
"categories: category 1 category 1",
"catalog_visibility": "hidden hidden",
}
I would really appreciate it if someone could point me in the right direction and give me a few tips on best practice
Since you're new to programming let's talk a little bit about arrays and hashtables.
Arrays are like lists (sometimes they are called lists too), specifically, ordered lists by position.
Hashtables are a type of dictionary, whereby you have a Key that corresponds to a Value.
In PowerShell the syntax you're using for creating an array is #() (that one's empty, it could contain items) and the syntax you use for creating a hashtable is #{} (also empty, could contain values).
You don't show your initial definition of $params, but based on the rest of the code I'm going to assume it's like this:
$params = #()
Then, you have this:
$params += #{
type= #();
name = #();
SKU = #();
catalog_visibility = #();
regular_price = #();
in_stock = #();
categories = #();
}
So what this would mean is that you took your array, $params, and added a new item to it. The new item is the hashtable literal you defined here. All the names you added, like type, name, SKU, etc. are Keys.
According to your desired output, it does look like you want an array of hashtables, so I think that part is correct.
But note that the values you assigned to them are all empty arrays. This is curious because what you showed as your desired output has each hashtable with those keys being singular values, so I think that's one issue, and in fact it's clouding the area where the problem really is.
So let's skip ahead to the body of the loop, where you use this pattern:
$params.type += $_.type
$params.SKU += $_.SKU
$params.name += $_.name
$params.catalog_visibility += $_.'Visibility in catalog'
$params.categories += $_.Categories
$params.regular_price += $_.'Regular price'
$params.in_stock += $_.'In stock?'
Remember that $params is an array, so you should have items in it starting at position 0, like $params[0], $params[1], etc. To change the SKU of the second hashtable in the array, you'd use $params[1].SKU or $params[1]['SKU'].
But what you're doing is just $params.SKU. In many languages, and indeed in PowerShell before v3, this would throw an error. The array itself doesn't have a property named SKU. In PowerShell v3 though the dot . operator was enhanced to allow it to introspect into an array and return each item's property with the given name, that is:
$a = #('apple','orange','pear')
$a.PadLeft(10,'~')
is the same as if we had done:
$a = #('apple','orange','pear')
$a | ForEach-Object { $_.PadLeft(10,'~') }
It's very useful but might be confusing you here.
So back to your object, $params is an array with, so far, only a single hashtable in it. And in your loop you aren't adding anything to $params.
Instead you ask for $params.SKU, which in this case will be the SKU of every hashtable in the array, but there's only one hashtable, so you only get one SKU.
Then you add to the SKU itself:
$params.SKU += $_.SKU
Here's the part where setting SKU initially to an empty array is hiding your issue. If SKU were a string, this would fail, because strings don't support +=, but since it's an array, you're taking this new value, and adding it to the array of SKUs that exist as the value of the single hashtable you're working against.
Where to go from here
don't use arrays for your values in this case
create a new hashtable in each iteration of your loop, then add that new hashtable to the $params array
Let's take a look:
$params = #()
$csv = Import-Csv C:\test\test-upload.csv
$csv | Select-Object -Property Type, SKU, Name, 'Visibility in catalog',
'Tax status', 'In stock?', Stock, 'Backorders allowed?', 'Allow customer
reviews?', 'Regular price', Categories | ForEach-Object {
$params += #{ # new hashtable here
type = $_.type
SKU = $_.SKU
name = $_.name
catalog_visibility = $_.'Visibility in catalog'
categories = $_.Categories
regular_price = $_.'Regular price'
}
}
This is the main problem you have, I left out the in stock part because I'm going to explain that logic separately.
$params.in_stock = $_.'In stock?'
if ($params.in_stock = 0) {$params.in_stock -replace 0, $false}
elseif($params.in_stock = 1) {$params.in_stock -replace 1, $true}
}
It looks like your CSV has an In stock? column that can be 0 or 1 for false/true.
First thing I'll address is that = in PowerShell is always assignment. Testing for equality is -eq, so:
$params.in_stock = $_.'In stock?'
if ($params.in_stock -eq 0) {$params.in_stock -replace 0, $false}
elseif($params.in_stock -eq 1) {$params.in_stock -replace 1, $true}
}
Next, let's talk about true/false values; they're called Boolean or bool for short, and you should usually use this data type to represent them. Any time you do a comparison for example like $a -eq 5 you're returning a bool.
There's strong support for converting other types to bool, for instance if you want to evaluate a number as bool, 0 is false, and all other values are true. For strings, a $null value or an empty string is false, all other values are true. Note that if you have a string "0" that is true because the string has a value.
That also means that the number 0 is not the same as the string '0', but PowerShell does attempt to do conversions between types, usually trying to convert the right side's type to the left side for comparison, so PowerShell will tell you 0 -eq '0' is true (same with '0' -eq 0).
And for your situation, reading from a CSV, those values will end up as strings, but because of the above, your equality tests will work anyway (it's just worth knowing the details).
The issue with your use of -replace though, is that it's a string operation, so even if it works, you're going to end up with the string representation of a boolean, not the actual bool, even though you said to use $true and $false directly (and this is again because of type conversion; -replace needs a string there, PowerShell converts your bool to string to satisfy it).
So, after that long-winded explanation, what makes sense then is this:
$params.in_stock = $_.'In stock?'
if ($params.in_stock -eq 0) {
$params.in_stock = $false
} elseif($params.in_stock -eq 1) {
$params.in_stock -eq $true
}
in fact, the elseif isn't necessary since you can only have 2 values:
$params.in_stock = $_.'In stock?'
if ($params.in_stock -eq 0) {
$params.in_stock = $false
} else {
$params.in_stock -eq $true
}
Even further though, we can use conversions to not need a conditional at all. Remember what I said about converting strings to numbers, and numbers to bool.
0 -as [bool] # gives false
"0" -as [bool] # gives true (whoops)
"0" -as [int] # gives the number 0
"0" -as [int] -as [bool] # false!
Now, we can do this:
$params.in_stock = $_.'In stock?' -as [int] -as [bool]
cool! Let's put it back into the other code:
$params = #()
$csv = Import-Csv C:\test\test-upload.csv
$csv | Select-Object -Property Type, SKU, Name, 'Visibility in catalog',
'Tax status', 'In stock?', Stock, 'Backorders allowed?', 'Allow customer
reviews?', 'Regular price', Categories | ForEach-Object {
$params += #{ # new hashtable here
type = $_.type
SKU = $_.SKU
name = $_.name
catalog_visibility = $_.'Visibility in catalog'
categories = $_.Categories
regular_price = $_.'Regular price'
in_stock = $_.'In stock?' -as [int] -as [bool]
}
}
Deeper dive!
Piping: you're doing some calls like the Import-Csv call and assigning its output to a variable, then piping that variable into another command. That's fine, it's not wrong, but you could also just pipe the first command's output directly into the second like so:
$params = #()
Import-Csv C:\test\test-upload.csv |
Select-Object -Property Type, SKU, Name, 'Visibility in catalog',
'Tax status', 'In stock?', Stock, 'Backorders allowed?', 'Allow customer
reviews?', 'Regular price', Categories |
ForEach-Object {
$params += #{ # new hashtable here
type = $_.type
SKU = $_.SKU
name = $_.name
catalog_visibility = $_.'Visibility in catalog'
categories = $_.Categories
regular_price = $_.'Regular price'
in_stock = $_.'In stock?' -as [int] -as [bool]
}
}
I updated to formatting a little to show that you can use a line break after a pipe |, which can look a little cleaner.
About Select-Object: its purpose is to take objects with a certain set of properties, and give you back a new object with a more limited (or sometimes with brand new) properties (it has other uses around changing the number of objects or filtering the array in other ways that aren't relevant here at the moment).
But I bring this up, because all the properties (columns) you're selecting are by name, and therefore must exist on the input object. And since you refer to each one later directly as opposed to display the entire thing, there's no reason to use Select-Object to filter down the properties, so that entire call can be removed:
$params = #()
Import-Csv C:\test\test-upload.csv |
ForEach-Object {
$params += #{ # new hashtable here
type = $_.type
SKU = $_.SKU
name = $_.name
catalog_visibility = $_.'Visibility in catalog'
categories = $_.Categories
regular_price = $_.'Regular price'
in_stock = $_.'In stock?' -as [int] -as [bool]
}
}
Nice! Looking slim.
About arrays and +=. This is ok in most cases to be honest, but you should know that each time you do this, in reality a new array is being created and all of the original items plus the new item are being copied into it. This doesn't scale, but again it's fine in most use cases.
What you should also know is that the output from a pipeline (like any command, or your main script code, or the body of ForEach-Object is all sent to the next command in the pipeline (or back out the left side if there's nothing else). This can be any number of items, and you can use assignment to get all of those values, like:
$a = Get-ChildItem $env:HOME # get all of items in the directory
$a will be an array if there's more than one item, and during processing it doesn't continually create and destroy arrays.
So how is this relevant to you? It means you don't have to make $params an empty array and append to it, just return your new hashtables in each loop iteration, and then assign the output of your pipeline right to $params!
$params = Import-Csv C:\test\test-upload.csv |
ForEach-Object {
#{ # new hashtable here
type = $_.type
SKU = $_.SKU
name = $_.name
catalog_visibility = $_.'Visibility in catalog'
categories = $_.Categories
regular_price = $_.'Regular price'
in_stock = $_.'In stock?' -as [int] -as [bool]
} # output is implicit
}
And now we've got your script down to a single pipeline (you could make it a single line but I prefer multi-line formatting).
So what you are doing is a lot of += to try and create an array, but you're doing it at the wrong level. What you want to do is create a hashtable (or quite possibly a PSCustomObject) for each item in the CSV, and capture them as an array of objects (be they hashtable objects, or PSCustomObject objects). So, let's try and restructure things a little to do that. I'm ditching the template, we don't care, we're defining it for each object anyway. I'm going to output a hashtable for each item in the ForEach-Object loop, and capture it in $params. This should give you the results you want.
$website = "https://www.mywebsite.com"
$csv = Import-Csv C:\test\test-upload.csv
$params = $csv | Select-Object -Property Type, SKU, Name, 'Visibility in catalog', 'Tax status', 'In stock?', Stock, 'Backorders allowed?', 'Allow customer reviews?', 'Regular price', Categories | ForEach-Object{
#{
type = $_.type
SKU = $_.SKU
name = $_.name
catalog_visibility = $_.'Visibility in catalog'
categories = $_.Categories
regular_price = $_.'Regular price'
in_stock = [boolean][int]($_.'In stock?')
}
}

Find matches in two different Powershell objects based on one property

I am trying to find the matching names in two different types of Powershell objects
$Object1 has two properties - Name (string), ResourceID (uint32)
$object2 has one noteproperty - Name (system.string)
This gives me a list of the matching names but I also want the corresponding resourceID property from $object1.
$computers = Compare-Object $Object1.name $WSD_CM12 | where {$_.sideindicator -eq "=>"} | foreach {$_.inputobject}
These are big objects with over 10,000 items so I'm looking for the most efficient way to accomplish this.
If I'm understanding what you're after, I'd start by creating a hash table from your Object1 collection:
$object1_hash = #{}
Foreach ($object1 in $object1_coll)
{ $object1_hash[$object1.Name] = $object1.ResourceID }
Then you can find the ResourceID for any given Object2.name with:
$object1_hash[$Object2.Name]
Test bed for creating hash table:
$object1_coll = $(
New-Object PSObject -Property #{Name = 'Name1';ResourceID = 001}
New-Object PSObject -Property #{Name = 'Name2';ResourceID = 002}
)
$object1_hash = #{}
Foreach ($object1 in $object1_coll)
{ $object1_hash[$object1.Name] = $object1.ResourceID }
$object1_hash
Name Value
---- -----
Name2 2
Name1 1
Alternative method:
# Create sample list of objects with both Name and Serial
$obj1 = New-Object -Type PSCustomObject -Property:#{ Name = "Foo"; Serial = "1234" }
$obj2 = New-Object -Type PSCustomObject -Property:#{ Name = "Cow"; Serial = "4242" }
$collection1 = #($obj1, $obj2)
# Create subset of items with only Name
$objA = New-Object -Type PSCustomObject -Property:#{ Name = "Foo"; }
$collection2 = #($objA)
#Everything above this line is just to make sample data
# replace $collection1 and $collection2 with $Object1, $WSD_CM12
# Combine into one list
($collection1 + $collection2) |
# Group by name property
Group-Object -Property Name |
# I only want items that exist in both
Where { $_.Count -gt 1 } |
# Now give me the object
Select -Expand Group |
# And get the properties
Where { $_.Serial -ne $null }