Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Copilot Instructions for PSFluentObjectValidation

## Project Overview
PSFluentObjectValidation is a PowerShell module that provides fluent syntax for validating complex object structures using dot notation with validation operators. The core functionality is implemented as a C# class embedded in PowerShell, supporting deep object traversal, array indexing, and wildcard validation.

## Architecture

### Core Components
- **C# Implementation**: `PSFluentObjectValidation/Private/PSFluentObjectValidation.ps1` contains the main logic as embedded C# code using `Add-Type`
- **Public Functions**: Thin PowerShell wrappers around the C# class
- `Test-Exist`: Safe validation that returns boolean
- `Assert-Exist`: Throws exceptions with detailed error messages
- **Module Structure**: Standard PowerShell module with Public/Private folder separation

### Key Design Patterns

#### Validation Syntax
The module uses a fluent dot notation with special operators:
- `property.nested` - Basic navigation
- `property!` - Non-empty validation (rejects null/empty/whitespace)
- `property?` - Existence validation (allows null values)
- `array[0]` - Array indexing
- `array[*]` - Wildcard validation (all elements must pass)

#### Error Handling Strategy
- `Test-Exist` wraps `Assert-Exist` in try/catch, never throws
- `Assert-Exist` provides detailed error messages with context
- C# implementation uses regex patterns for parsing validation operators

## Development Workflows

### Build System (psake + PowerShellBuild)
```powershell
# Bootstrap dependencies first (one-time setup)
./build.ps1 -Bootstrap

# Standard development workflow
./build.ps1 -Task Build # Compiles and validates module
./build.ps1 -Task Test # Runs Pester tests + analysis
./build.ps1 -Task Clean # Cleans output directory
```

The build uses PowerShellBuild tasks defined in `psakeFile.ps1`. The `requirements.psd1` manages all build dependencies including Pester 5.4.0, PSScriptAnalyzer, and psake.

### Testing Strategy
Tests live in `tests/` directory following these patterns:
- `Manifest.tests.ps1` - Module manifest validation
- `Meta.tests.ps1` - Code quality and PSScriptAnalyzer rules
- `Help.tests.ps1` - Documentation validation
- Use `ScriptAnalyzerSettings.psd1` for custom analysis rules

### Module Compilation
The module uses **non-monolithic** compilation (`$PSBPreference.Build.CompileModule = $false`), preserving individual Public/Private .ps1 files in the output rather than combining into a single .psm1.

## Critical Implementation Details

### C# Embedded Code Patterns
When modifying the C# implementation:
- Use `Add-Type` with `ReferencedAssemblies` for System.Management.Automation
- Regex patterns are compiled for performance: `PropertyWithValidation`, `ArrayIndexPattern`
- Support multiple object types: Hashtables, PSObjects, .NET objects, Arrays, IList, IEnumerable

### Array Processing
The `WildcardArrayWrapper` class enables wildcard validation by:
1. Wrapping array objects during `[*]` processing
2. Validating properties exist on ALL elements
3. Returning first element's value for continued navigation

### Property Resolution Order
1. Check for array indexing pattern `property[index]`
2. Check for validation suffixes `property!` or `property?`
3. Handle wildcard array wrapper context
4. Fall back to regular property navigation

## Common Patterns

### Adding New Validation Operators
1. Update regex patterns in C# code
2. Add case handling in `ProcessPropertyWithValidation`
3. Add validation logic in `ValidatePropertyValue`
4. Update documentation and examples

### Testing Complex Object Structures
Use the fluent syntax patterns from README.md:
```powershell
# Deep nesting with validation
Test-Exist -In $data -With "users[0].profile.settings.theme!"

# Wildcard array validation
Test-Exist -In $data -With "users[*].email!"

# Mixed indexing and wildcards
Test-Exist -In $data -With "orders[1].items[*].price"
```

### Error Message Conventions
- Include property path context: `"Property 'user.name' does not exist"`
- For arrays: `"Array index [10] is out of bounds for 'users' (length: 5)"`
- For wildcards: `"Property 'email' in element [2] is empty"`

## Cross-Platform Considerations
The module targets PowerShell 5.1+ and supports Windows/Linux/macOS. The CI pipeline tests on all three platforms using GitHub Actions with the psmodulecache action for dependency management.
2 changes: 1 addition & 1 deletion .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"windows": {
"options": {
"shell": {
"executable": "powershell.exe",
"executable": "pwsh.exe",
"args": [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command" ]
}
}
Expand Down
26 changes: 23 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,32 @@
All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.1] Released
## [1.0.2] - 2025-09-24

### Added

- Added test cases

### Fixed

- Fixed a typo in Assert-Exists where the With alias was called Width
- Fixed an issue with multi level array validation tests[*].users[1] was failing to properly validate before

### Changed

- Updated README
- Updated CHANGELOG

## [1.0.1] - 2025-09-23

### Fixed

- Fixing issue with powershell 5.1 compiling the c# code.

## [1.0.0] Released
## [1.0.0] - 2025-09-23

### Added

- Initial release
8 changes: 4 additions & 4 deletions PSFluentObjectValidation/PSFluentObjectValidation.psd1
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
@{
RootModule = 'PSFluentObjectValidation.psm1'
ModuleVersion = '1.0.1'
ModuleVersion = '1.0.2'
GUID = '90ac3c83-3bd9-4da5-8705-7b82b21963c8'
Author = 'Joshua Wilson'
CompanyName = 'PwshDevs'
Copyright = '(c) 2025 PwshDevs. All rights reserved.'
Description = 'Contains a helper class and functions to validate objects.'
PowerShellVersion = '5.1'
FunctionsToExport = @('Test-Exists', 'Assert-Exists')
FunctionsToExport = @('Test-Exist', 'Assert-Exist')
CmdletsToExport = @()
VariablesToExport = '*'
AliasesToExport = @('exists', 'asserts', 'tests')
PrivateData = @{
PSData = @{
Tags = @('Validation', 'Object', 'Fluent', 'Helper', 'Assert', 'Test', 'Exists')
Tags = @('PSEdition_Desktop', 'PSEdition_Core', 'Windows', 'Linux', 'MacOS', 'Validation', 'Object', 'Fluent', 'Helper', 'Assert', 'Test', 'Exists')
LicenseUri = 'https://github.com/pwshdevs/PSFluentObjectValidation/blob/main/LICENSE'
ProjectUri = 'https://github.com/pwshdevs/PSFluentObjectValidation'
ReleaseNotes = ''
ReleaseNotes = 'https://github.com/pwshdevs/PSFluentObjectValidation/blob/main/CHANGELOG.md'
}
}
}
115 changes: 114 additions & 1 deletion PSFluentObjectValidation/Private/PSFluentObjectValidation.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public static class PSFluentObjectValidation
if (inputObject == null)
throw new ArgumentException("InputObject cannot be null");

if (string.IsNullOrEmpty(key))
if (String.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be null or empty");

string[] keyParts = key.Split('.');
Expand All @@ -42,6 +42,13 @@ public static class PSFluentObjectValidation

private static object ProcessKeyPart(object currentObject, string part)
{
// Handle wildcard array wrapper specially
if (currentObject is WildcardArrayWrapper)
{
WildcardArrayWrapper wrapper = (WildcardArrayWrapper)currentObject;
return ProcessWildcardPropertyAccess(wrapper.ArrayObject, part);
}

// Check for array indexing: property[index] or property[*]
Match arrayMatch = ArrayIndexPattern.Match(part);
if (arrayMatch.Success)
Expand Down Expand Up @@ -225,6 +232,112 @@ public static class PSFluentObjectValidation

private static object ProcessWildcardPropertyAccess(object arrayObject, string propertyName)
{
// First check if this is an array indexing pattern: property[index] or property[*]
Match arrayMatch = ArrayIndexPattern.Match(propertyName);
if (arrayMatch.Success)
{
string basePropertyName = arrayMatch.Groups[1].Value;
string indexStr = arrayMatch.Groups[2].Value;

// Handle array indexing after wildcard: items[0], tags[*], etc.
if (arrayObject is Array)
{
Array array = (Array)arrayObject;
for (int i = 0; i < array.Length; i++)
{
object element = array.GetValue(i);
if (element == null)
throw new InvalidOperationException(String.Format("Array element [{0}] is null", i));

if (!HasProperty(element, basePropertyName))
throw new InvalidOperationException(String.Format("Array element [{0}] does not have property '{1}'", i, basePropertyName));

object propertyValue = GetProperty(element, basePropertyName);
if (propertyValue == null)
throw new InvalidOperationException(String.Format("Property '{0}' in element [{1}] is null", basePropertyName, i));
if (!IsArrayLike(propertyValue))
throw new InvalidOperationException(String.Format("Property '{0}' in element [{1}] is not an array", basePropertyName, i));
}

// All elements are valid, now handle the indexing
object firstElement = array.GetValue(0);
object firstPropertyValue = GetProperty(firstElement, basePropertyName);

if (indexStr == "*")
{
return new WildcardArrayWrapper(firstPropertyValue);
}
else
{
int index = int.Parse(indexStr);
int count = GetCount(firstPropertyValue);
if (index < 0 || index >= count)
throw new InvalidOperationException(String.Format("Array index [{0}] is out of bounds for property '{1}' (length: {2})", index, basePropertyName, count));

if (firstPropertyValue is Array)
{
Array firstArray = (Array)firstPropertyValue;
return firstArray.GetValue(index);
}
if (firstPropertyValue is IList)
{
IList firstList = (IList)firstPropertyValue;
return firstList[index];
}
}
}

if (arrayObject is IList)
{
IList list = (IList)arrayObject;
for (int i = 0; i < list.Count; i++)
{
object element = list[i];
if (element == null)
throw new InvalidOperationException(String.Format("Array element [{0}] is null", i));
if (!HasProperty(element, basePropertyName))
throw new InvalidOperationException(String.Format("Array element [{0}] does not have property '{1}'", i, basePropertyName));

object propertyValue = GetProperty(element, basePropertyName);
if (propertyValue == null)
throw new InvalidOperationException(String.Format("Property '{0}' in element [{1}] is null", basePropertyName, i));
if (!IsArrayLike(propertyValue))
throw new InvalidOperationException(String.Format("Property '{0}' in element [{1}] is not an array", basePropertyName, i));
}

// All elements are valid, now handle the indexing
object firstElement = list[0];
object firstPropertyValue = GetProperty(firstElement, basePropertyName);

if (indexStr == "*")
{
return new WildcardArrayWrapper(firstPropertyValue);
}
else
{
int index = int.Parse(indexStr);
int count = GetCount(firstPropertyValue);
if (index < 0 || index >= count)
throw new InvalidOperationException(String.Format("Array index [{0}] is out of bounds for property '{1}' (length: {2})", index, basePropertyName, count));

if (firstPropertyValue is Array)
{
Array firstArray = (Array)firstPropertyValue;
return firstArray.GetValue(index);
}
if (firstPropertyValue is IList)
{
IList firstList = (IList)firstPropertyValue;
return firstList[index];
}
}
}

throw new InvalidOperationException(String.Format("Cannot process wildcard array indexing on type {0}", arrayObject.GetType().Name));
}



// Parse validation suffix if present
Match validationMatch = PropertyWithValidation.Match(propertyName);
string actualPropertyName = propertyName;
Expand Down
44 changes: 42 additions & 2 deletions PSFluentObjectValidation/Public/Assert-Exist.ps1
Original file line number Diff line number Diff line change
@@ -1,14 +1,54 @@
<#
.SYNOPSIS
Asserts the existence and validity of a property within an object.

.DESCRIPTION
The `Assert-Exist` function validates the existence of a property within an object and ensures it meets the specified validation criteria. If the validation fails, it throws a detailed exception, making it suitable for scenarios where strict validation is required.

.PARAMETER InputObject
The object to validate. This can be a hashtable, PSObject, .NET object, or any other object type.

.PARAMETER Key
The property path to validate. Supports fluent syntax with validation operators:
- `property.nested` - Basic navigation
- `property!` - Non-empty validation (rejects null/empty/whitespace)
- `property?` - Existence validation (allows null values)
- `array[0]` - Array indexing
- `array[*]` - Wildcard validation (all elements must pass)

.EXAMPLE
Assert-Exist -InputObject $data -Key "user.name!"

Asserts that the `user.name` property exists and is non-empty
.EXAMPLE
Assert-Exist -InputObject $data -Key "users[*].email!"

Asserts that all users in the array have a non-empty email
.EXAMPLE
Assert-Exist -InputObject $data -Key "settings.theme"

Asserts that the `settings.theme` property exists
.NOTES
Throws an exception if the validation fails. Use `Test-Exist` for a non-throwing alternative.

.LINK
https://www.pwshdevs.com/
#>
function Assert-Exist {
param(
[Parameter(Mandatory=$true)]
[Alias('In')]
$InputObject,
[Parameter(Mandatory=$true, ValueFromPipeline = $true)]
[Alias('Width', 'Test')]
[Alias('With', 'Test')]
[string]$Key
)

[PSFluentObjectValidation]::AssertExists($InputObject, $Key)
Begin { }

Process {
[PSFluentObjectValidation]::AssertExists($InputObject, $Key)
}
}

New-Alias -Name asserts -Value Assert-Exist
Loading
Loading