1

I've written a Powershell cmdlet in C# which returns details about one or more employees' direct manager(s) using a home-grown API. The cmdlet is supposed to return a collection of one or more objects of type Associate. The problem I'm having is that the output type of the Cmdlet is not consistent.

I designed the Cmdlet such that if you already have a collection of Associate objects, you can pass that in via the pipeline. Otherwise, you need to pass in one or more userIds under the -Identity parameter.

Here is the behavior I'm seeing, though in terms of the Cmdlet output:

  • If I pass in one or more userIds with the -Identity parameter, I get the expected collection of Associate:
    > $test1 = Get-Manager -Identity 'user1','user2'
    > $test1.GetType()

    IsPublic IsSerial Name                                     BaseType
    -------- -------- ----                                     --------
    True     True     List`1                                   System.Object


    PS H:\> $test1 | select displayName

    displayName
    -----------
    John Doe
    Jane Lee
  • If I pass in one or more Associate objects by explicitly using the -Assoc parameter, I also get the expected collection
    > $folks = Get-Associate 'brunomik','abcdef2'
    > $test2 = Get-Manager -Assoc $folks
    > $test2.getType()

    IsPublic IsSerial Name                                     BaseType
    -------- -------- ----                                     --------
    True     True     List`1                                   System.Object


    PS H:\> $test2 | Select displayName

    displayName
    -----------
    John Doe
    Jane Lee
  • However, if I pass in a collection of Associate objects using the pipeline, I seem to get back a multidimensional array!:
    > $test3 = $folks | Get-Manager
    > $test3.GetType()

    IsPublic IsSerial Name                                     BaseType
    -------- -------- ----                                     --------
    True     True     Object[]                                 System.Array


    > $test3 | select displayName

    displayName
    -----------


    ># Select-Object can't find a property called displayName
    ># But if I run GetType() on the first element of the collection:
    > $test3[0].GetType()

    IsPublic IsSerial Name                                     BaseType
    -------- -------- ----                                     --------
    True     True     List`1                                   System.Object

    ># It appears to be yet another collection!
    ># Now, if I run Select-Object on that first element of $test3, I do see the data:
    > $test3[0] | Select displayName

    displayName
    -----------
    John Doe
    Jane Lee

Here is the source code for the Cmdlet:

    [Cmdlet(VerbsCommon.Get, "Manager", DefaultParameterSetName = @"DefaultParamSet")]
    [OutputType(typeof(Associate))]
    public class GetManager : Cmdlet
    {
        private Associate[] assoc = null;
        private string[] identity = null;

        private bool assocSet = false;
        private bool identitySet = false;


        //The Assoc parameter supports the pipeline and accepts one or more objects of type Associate
        [Parameter(ParameterSetName = @"DefaultParamSet",
                   ValueFromPipeline = true,
                   HelpMessage = "An Associate object as returned by the \"Get-Associate\" cmdlet. Cannot be used with the \"Identity\" parameter")]
        public Associate[] Assoc
        {
            get
            {
                return assoc;
            }
            set
            {
                assoc = value;
                assocSet = true;
            }
        }

        //The Identity parameter accepts one or more string expressions (user IDs)
        [Parameter(HelpMessage = "An Associate user Id. Not to be used with the \"Assoc\" parameter")]
        public string[] Identity
        {
            get
            {
                return identity;
            }
            set
            {
                identitySet = true;
                identity = value;
            }
        }

        //This will contain the output of the Cmdlet
        private List<Associate> Result = new List<Associate>();

        protected override void BeginProcessing()
        {
            base.BeginProcessing();
        }

        protected override void ProcessRecord()
        {
            base.ProcessRecord();
            BuildOutputObject();
            WriteObject(Result);
        }

        //Builds the Cmdlet Output object
        private void BuildOutputObject()
        {
            List<Associate> Subordinates = new List<Associate>();

            //Only the Assoc or Identity parameter may be set; not both.
            if (!(assocSet ^ identitySet))
            {
                throw new ApplicationException($"Either the {nameof(Assoc).InQuotes()} or the {nameof(Identity).InQuotes()} parameter must be set, but not both.");
            }

            //If Assoc is set, we already have an array of Associate objects, so we'll simply define Subordinates by calling Assoc.ToList()
            if (assocSet)
            {
                Subordinates = Assoc.ToList();
            }

            //Otherwise, we'll need to create an associate object from each userID passed in with the "Identity" parameter.  The MyApi.GetAssociates() method returns a list of Associate objects.
            else
            {
                Subordinates = MyApi.GetAssociates(Identity);
                if (!MyApi.ValidResponse)
                {
                    throw new ApplicationException($"No associate under the identifiers {string.Join(",",Identity).InQuotes()} could be found.");
                }
            }

            //Now, to build the output object:
            Subordinates.ForEach(p => Result.Add(p.GetManager()));
        }
    }
0

1 Answer 1

1

ProcessRecord is executed once per input argument.

As a result, when you call Get-Manager -Identity A,B, PowerShell:

  • Resolves the appropriate parameter set (if necessary)
  • Invokes BeginProcessing()
  • Binds value A,B to Identity
  • Invokes ProcessRecord()
  • Invokes EndProcessing()

When you pipe an equivalent array to it (eg. "A","B" |Get-Manager), PowerShell enumerates the input and binds the items to the appropriate parameter one-by-one instead - that is, PowerShell:

  • Resolves the appropriate parameter set (if necessary)
  • Invokes BeginProcessing()
  • Binds value A to Identity
  • Invokes ProcessRecord()
  • Binds value B to Identity
  • Invokes ProcessRecord()
  • Invokes EndProcessing()

... resulting in 2 List<Associate>'s, instead of one.

The "solution" is to either:

  1. not return concrete collections types as output objects, or
  2. "collect" partial output in ProcessRecord, then output once, in EndProcessing.

1. No wrapping in IEnumerable types

This approach closely resembles an iterator method in C# - think of WriteObject(obj); as PowerShell's version of yield return obj;:

protected override void ProcessRecord()
{
    base.ProcessRecord();
    BuildOutputObject();
    foreach(var obj in Result)
      WriteObject(obj);
}

WriteObject() also has an overload that enumerates the object for you, so the simplest fix is actually just:

protected override void ProcessRecord()
{
    base.ProcessRecord();
    BuildOutputObject();
    WriteObject(Result, true);
}

This first option is by far the most preferable, as it allows us to take optimal advantage of performance characteristics of PowerShell's pipeline processor.

2. Accumulate output, WriteObject() in EndProcessing():

private List<Associate> finalResult = new List<Associate>();

protected override void ProcessRecord()
{
    base.ProcessRecord();
    BuildOutputObject();
    # Accumulate output
    finalResult.AddRange(Result)
}

protected override void EndProcessing()
{
    WriteObject(finalResult);
}

Omitting the second argument from WriteObject and only calling it once, will preserve the type of finalResult, but you will be blocking any downstream cmdlets from executing until this one is done processing all input

Sign up to request clarification or add additional context in comments.

2 Comments

Thanks! Your first suggestion did the trick, except for the fact that I'm getting an extra instance of the first manager in the output. This happens whether I use a loop or the WriteObject() overload. No a deal breaker, though as I can just use DistinctBy() from MoreLinq to remove any dupes.
You can solve that with Result.Clear() after each WriteObject() call :) or by dropping all the indirection, by making BuildOutputObject() return its output and then call WriteObject(BuildOutputObject(), true) instead (that would be my preference, Result is entirely unnecessary)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.