Skip to content

Centralize ExcludeProperty filter application in ViewGenerator base class#26574

Merged
iSazonov merged 5 commits intoPowerShell:masterfrom
yotsuda:fix-issue-26568-centralize-excludefilter
Dec 10, 2025
Merged

Centralize ExcludeProperty filter application in ViewGenerator base class#26574
iSazonov merged 5 commits intoPowerShell:masterfrom
yotsuda:fix-issue-26568-centralize-excludefilter

Conversation

@yotsuda
Copy link
Copy Markdown
Contributor

@yotsuda yotsuda commented Dec 5, 2025

PR Summary

This PR centralizes the ExcludeProperty filter application logic in the ViewGenerator base class, addressing the enhancement requested in #26568.

Changes

ViewGenerator base class:

  • Changed activeAssociationList from protected field to private field (_activeAssociationList)
  • Added ActiveAssociationList read-only property (returns IReadOnlyList<> for safety)
  • Added BuildRawAssociationList(PSObject so, List<MshParameter> propertyList) virtual method for subclasses to override
  • Added GetActiveAssociationList() template method that extracts both propertyList and excludeFilter from parameters and passes them explicitly to the respective methods
  • Added ResetActiveAssociationList() method for List/Wide per-object processing
  • Added internal static ApplyExcludeFilter() for reuse in ComplexViewObjectBrowser

Table/List/Wide ViewGenerators:

  • Refactored to use the template method pattern
  • Override BuildRawAssociationList(PSObject so, List<MshParameter> propertyList) to provide cmdlet-specific property expansion
  • Subclasses no longer access parameters field directly; they receive propertyList as a method parameter
  • Filter application is now structurally guaranteed (cannot be forgotten)

Complex ViewGenerator:

  • Replaced inline filtering logic with ViewGenerator.ApplyExcludeFilter() call
  • Removed using System.Linq; (no longer needed)

PR Context

PR #26514 added the -ExcludeProperty parameter to Format-* cmdlets. However, each ViewGenerator subclass had to manually call ApplyExcludePropertyFilter() after building the association list. This approach had two issues:

  1. Future implementations: New ViewGenerator subclasses could forget to apply the filter
  2. Discoverability: The base class didn't make this requirement obvious

This PR addresses these concerns by:

  • Making _activeAssociationList private so subclasses cannot directly assign to it
  • Providing GetActiveAssociationList() which automatically applies the filter
  • Establishing a clear contract via BuildRawAssociationList() that subclasses override

Note: ComplexViewGenerator uses a recursive architecture with ComplexViewObjectBrowser (a separate class), so it cannot fully adopt the template method pattern. However, code duplication is eliminated by reusing ViewGenerator.ApplyExcludeFilter().

Design Decision: Explicit Parameter Passing

The template method GetActiveAssociationList() extracts both propertyList and excludeFilter from the parameters field at the same level, then passes them explicitly to the respective methods:

protected List<MshResolvedExpressionParameterAssociation> GetActiveAssociationList(PSObject so)
{
    if (_activeAssociationList is null)
    {
        var propertyList = parameters?.mshParameterList;
        var excludeFilter = parameters?.excludePropertyFilter;
        var rawList = BuildRawAssociationList(so, propertyList);
        _activeAssociationList = ApplyExcludeFilter(rawList, excludeFilter);
    }

    return _activeAssociationList;
}

This design ensures:

  • Symmetry: Both propertyList and excludeFilter are retrieved at the same abstraction level
  • Explicit data flow: Subclasses receive propertyList as a parameter instead of accessing parameters directly
  • Separation of concerns: BuildRawAssociationList() handles property expansion; ApplyExcludeFilter() handles filtering

Design Decision: Lazy Initialization vs Constructor

The ViewGenerator class uses an Initialize() method pattern rather than a constructor, which is an existing design in this codebase. Within this pattern, the association list is built lazily via GetActiveAssociationList() rather than eagerly in Initialize() because different subclasses have different timing requirements:

ViewGenerator When to build Reason
Table Once in Initialize() Header columns are determined by the first object and remain fixed
List Each object, then reset Different objects may have different property sets
Wide Each object, then reset Different objects may have different property sets

The lazy initialization pattern with GetActiveAssociationList() and ResetActiveAssociationList() provides flexibility for subclasses to call these methods at the appropriate time for their specific formatting needs.

Design Decision: virtual vs abstract for BuildRawAssociationList()

Here is the mapping of cmdlets to ViewGenerator classes and their template method adoption:

Cmdlet ViewGenerator Class Uses Template Method Notes
Format-Table TableViewGenerator ✅ Yes Overrides BuildRawAssociationList()
Format-List ListViewGenerator ✅ Yes Overrides BuildRawAssociationList()
Format-Wide WideViewGenerator ✅ Yes Overrides BuildRawAssociationList()
Format-Custom ComplexViewGenerator ❌ No Does not override BuildRawAssociationList(); uses recursive architecture

The general pattern is that subclasses must implement BuildRawAssociationList(). ComplexViewGenerator (used by Format-Custom) is the exception because it delegates to a separate class for recursive object traversal.

Currently, BuildRawAssociationList() is declared as virtual with a default implementation that returns null. I'd like feedback on whether this should be abstract instead.

Approach Pros Cons
virtual (current) No changes needed for ComplexViewGenerator New subclasses could forget to override; would result in NullReferenceException at runtime
abstract Compile-time enforcement; impossible to forget Requires ComplexViewGenerator to have an explicit opt-out implementation (e.g., BuildRawAssociationList() returns null or throws NotSupportedException)

If abstract is preferred, I can update the PR.

PR Checklist

@yotsuda yotsuda marked this pull request as draft December 5, 2025 22:33
@yotsuda yotsuda marked this pull request as ready for review December 5, 2025 23:05
@iSazonov iSazonov added the CL-CodeCleanup Indicates that a PR should be marked as a Code Cleanup change in the Change Log label Dec 6, 2025
/// <summary>
/// Gets the current active association list for read-only access.
/// </summary>
protected IReadOnlyList<MshResolvedExpressionParameterAssociation> ActiveAssociationList => _activeAssociationList;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'd prefer use exactly List type. We have no need to use generics and pay for virtual method.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Changed to List<MshResolvedExpressionParameterAssociation> in 0e53e55.

Comment on lines +516 to +517
// Apply ExcludeProperty filter using the centralized method
activeAssociationList = ViewGenerator.ApplyExcludeFilter(activeAssociationList, _parameters?.excludePropertyFilter);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'd expect we benefit from streaming here too. I mean something like - if we set arguments in constructor and SetupActiveProperties we can get filtered list with ActiveAssociationList property.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for the suggestion.

Could you clarify the design you have in mind? I want to make sure I understand correctly.

Currently, ComplexViewObjectBrowser is an independent class (not inheriting from ViewGenerator). It processes nested objects by calling DisplayObject recursively - each call handles a different PSObject and creates a new association list via AssociationManager.SetupActiveProperties.

Are you suggesting to:

  • Extend AssociationManager.SetupActiveProperties to accept an optional excludeFilter parameter?
  • Or restructure ComplexViewObjectBrowser to use ViewGenerator's ActiveAssociationList pattern?

Also, replacing this recursive structure with an iterative approach using explicit stack might enable further streaming optimizations, but that would be a larger architectural change. Should I file a separate issue for that?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think it's worth it. It's better to spend time on something more useful.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for the feedback. I'll keep the current implementation.

}

this.activeAssociationList = null;
ResetActiveAssociationList();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks like disposing pattern. But I guess it is too many changes to follow the pattern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, agreed.

@iSazonov iSazonov self-assigned this Dec 8, 2025
protected List<MshResolvedExpressionParameterAssociation> GetActiveAssociationList(PSObject so)
{
if (this.parameters is not null && this.parameters.excludePropertyFilter is not null)
if (_activeAssociationList is null)
Copy link
Copy Markdown
Collaborator

@iSazonov iSazonov Dec 8, 2025

Choose a reason for hiding this comment

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

I see here possible problem. If so parameter was changed we returned old list.
Does it make sense to keep the list and manually reset it in ResetActiveAssociationList()?
If yes we should check for so change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for pointing this out.

You're right. The reset pattern wasn't necessary - I've removed it.

Base class now provides BuildActiveAssociationList(PSObject so) which always builds a new list (no caching). Only Table caches locally since it needs to retain the list across method calls.

Changes in ca18256:

  • FormatViewGenerator.cs: Removed _activeAssociationList, ActiveAssociationList, ResetActiveAssociationList(); renamed GetActiveAssociationListBuildActiveAssociationList
  • FormatViewGenerator_Table.cs: Added local _activeAssociationList and ActiveAssociationList
  • FormatViewGenerator_List.cs, FormatViewGenerator_Wide.cs: Use BuildActiveAssociationList() directly, removed Reset calls

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR successfully refactors the ExcludeProperty filter application logic across the formatting subsystem using the template method design pattern. The changes centralize filter logic that was previously duplicated across multiple ViewGenerator subclasses, making it structurally impossible for future implementations to forget applying the filter.

Key Changes

  • Template method pattern in ViewGenerator base class: Introduces BuildActiveAssociationList() that orchestrates the property resolution and filtering, delegating raw list building to subclass-specific BuildRawAssociationList() overrides
  • Consistent filter application: ApplyExcludeFilter() is now a reusable internal static method used by both the template method pattern and ComplexViewObjectBrowser
  • Simplified subclass implementations: TableViewGenerator, ListViewGenerator, and WideViewGenerator now only override BuildRawAssociationList() with their format-specific logic, leaving filtering to the base class

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
FormatViewGenerator.cs Adds template method pattern infrastructure: BuildRawAssociationList() virtual method, BuildActiveAssociationList() orchestration method, and ApplyExcludeFilter() static utility
FormatViewGenerator_Table.cs Overrides BuildRawAssociationList() with table-specific property expansion logic; maintains private cache of filtered association list; refactors LimitAssociationListSize() to be static
FormatViewGenerator_List.cs Overrides BuildRawAssociationList() delegating to AssociationManager.SetupActiveProperties(); removes per-object manual filter application
FormatViewGenerator_Wide.cs Overrides BuildRawAssociationList() with wide-specific property expansion; removes per-object manual filter application
FormatViewGenerator_Complex.cs Replaces inline LINQ filtering with centralized ViewGenerator.ApplyExcludeFilter() call; removes unused System.Linq import

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +19 to +22
/// <summary>
/// Gets the cached active association list.
/// </summary>
private List<MshResolvedExpressionParameterAssociation> ActiveAssociationList => _activeAssociationList;
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

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

The ActiveAssociationList property is redundant as it's a simple getter for the _activeAssociationList field within the same private scope. Consider removing this property and accessing _activeAssociationList directly throughout the class to reduce unnecessary indirection.

Suggested change
/// <summary>
/// Gets the cached active association list.
/// </summary>
private List<MshResolvedExpressionParameterAssociation> ActiveAssociationList => _activeAssociationList;
// Removed redundant property ActiveAssociationList.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Removed the redundant property and now accessing _activeAssociationList directly, consistent with how _tableBody is used in this class. (569f600)

Comment on lines +370 to +372
var propertyList = parameters?.mshParameterList;
var excludeFilter = parameters?.excludePropertyFilter;
var rawList = BuildRawAssociationList(so, propertyList);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please replace var-s with real type.

@iSazonov iSazonov merged commit 235ff05 into PowerShell:master Dec 10, 2025
36 checks passed
@iSazonov
Copy link
Copy Markdown
Collaborator

@yotsuda Thanks! Rest of comments are minor. Also you could consider to turn on nullability. (It would be great in any code.)

@yotsuda yotsuda deleted the fix-issue-26568-centralize-excludefilter branch December 10, 2025 21:46
SIRMARGIN pushed a commit to SIRMARGIN/PowerShell that referenced this pull request Dec 12, 2025
@daxian-dbw
Copy link
Copy Markdown
Member

@iSazonov Is the issue #26568 considered fixed by this PR? If so, please close the issue as 'resolution-fixed'. If not, please add comment there to call out what else if missing after this fix. Thanks!

@iSazonov
Copy link
Copy Markdown
Collaborator

iSazonov commented Dec 18, 2025

@daxian-dbw Ah! I'm used to them closing automatically. :-) Thanks for reminding me! I closed the issue.

kilasuit pushed a commit to kilasuit/PowerShell that referenced this pull request Jan 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CL-CodeCleanup Indicates that a PR should be marked as a Code Cleanup change in the Change Log

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants