diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a45d404 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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. diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d318c20..25c15f8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,7 +7,7 @@ "windows": { "options": { "shell": { - "executable": "powershell.exe", + "executable": "pwsh.exe", "args": [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command" ] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 386a34a..bd78831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PSFluentObjectValidation/PSFluentObjectValidation.psd1 b/PSFluentObjectValidation/PSFluentObjectValidation.psd1 index 9cb9232..0b1541c 100644 --- a/PSFluentObjectValidation/PSFluentObjectValidation.psd1 +++ b/PSFluentObjectValidation/PSFluentObjectValidation.psd1 @@ -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' } } } diff --git a/PSFluentObjectValidation/Private/PSFluentObjectValidation.ps1 b/PSFluentObjectValidation/Private/PSFluentObjectValidation.ps1 index 39bfc7e..742c7a6 100644 --- a/PSFluentObjectValidation/Private/PSFluentObjectValidation.ps1 +++ b/PSFluentObjectValidation/Private/PSFluentObjectValidation.ps1 @@ -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('.'); @@ -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) @@ -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; diff --git a/PSFluentObjectValidation/Public/Assert-Exist.ps1 b/PSFluentObjectValidation/Public/Assert-Exist.ps1 index e634d39..e6a2380 100644 --- a/PSFluentObjectValidation/Public/Assert-Exist.ps1 +++ b/PSFluentObjectValidation/Public/Assert-Exist.ps1 @@ -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 diff --git a/PSFluentObjectValidation/Public/Test-Exist.ps1 b/PSFluentObjectValidation/Public/Test-Exist.ps1 index 96540e4..ea80bca 100644 --- a/PSFluentObjectValidation/Public/Test-Exist.ps1 +++ b/PSFluentObjectValidation/Public/Test-Exist.ps1 @@ -1,3 +1,39 @@ +<# +.SYNOPSIS +Tests the existence and validity of a property within an object. + +.DESCRIPTION +The `Test-Exist` function validates the existence of a property within an object and ensures it meets the specified validation criteria. Unlike `Assert-Exist`, this function does not throw exceptions; instead, it returns a boolean value indicating whether the validation passed. + +.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 +Test-Exist -InputObject $data -Key "user.name!" + +Tests that the `user.name` property exists and is non-empty. +.EXAMPLE +Test-Exist -InputObject $data -Key "users[*].email!" + +Tests that all users in the array have a non-empty email. +.EXAMPLE +Test-Exist -InputObject $data -Key "settings.theme" + +Tests that the `settings.theme` property exists. +.NOTES +Returns `$true` if the validation passes, `$false` otherwise. Use `Assert-Exist` for a throwing alternative. + +.LINK +https://www.pwshdevs.com/ +#> Function Test-Exist { param( [Parameter(Mandatory=$true)] @@ -8,7 +44,10 @@ Function Test-Exist { [string]$Key ) - return [PSFluentObjectValidation]::TestExists($InputObject, $Key) + Begin { } + Process { + return [PSFluentObjectValidation]::TestExists($InputObject, $Key) + } } New-Alias -Name exists -Value Test-Exist diff --git a/README.md b/README.md index fcb5f50..6a224b7 100644 --- a/README.md +++ b/README.md @@ -6,29 +6,27 @@ **PSGallery** -[![PowerShell Gallery][psgallery-badge]][psgallery] [![PSGallery Version][psgallery-version-badge]][psgallery] +[![PowerShell Gallery][psgallery-badge]][psgallery] [![PSGallery Version][psgallery-version-badge]][psgallery] [![PSGallery Playform][psgallery-platform-badge]][psgallery] [![PSGallery Playform][ps-desktop-badge]][psgallery] ## General Overview -The PSFluentObjectValidation represents a comprehensive evolution from simple property validation to a high-performance, feature-rich validation engine with advanced array indexing capabilities. - ## Installation ```powershell Install-PSResource -Name PSFluentObjectValidation -# or +``` + +or + +```powershell Install-Module -Name PSFluentObjectValidation +``` -# Verify installation (long style) -Test-Exists -in @{ test = "value" } -with "test!" -# (short hand) -exists -with "test!" -in @{ test = "value" } -# or alternative -tests -with "test!" -in @{ test = "value" } -# or if you'd rather have thrown errors -Assert-Exists -in @{ test = "value" } -with "test!" -# or you can use a short hand version -asserts -with "test!" -in @{ test = "value" } +Once installed, you can run a couple of tests to verify it works as expected: + +```powershell +# Verify installation +Test-Exist -In @{ test = "value" } -With "test!" # Should return: True ``` @@ -37,18 +35,23 @@ asserts -with "test!" -in @{ test = "value" } ### Basic Property Access ```powershell -exists -with "user.name" -in $data # Simple property -exists -with "user.profile.age" -in $data # Nested objects -exists -with "config.db.host" -in $data # Deep nesting +Test-Exist -With "user.name" -In $data # Simple property +Test-Exist -With "user.profile.age" -In $data # Nested objects +Test-Exist -With "config.db.host" -In $data # Deep nesting ``` -As an example, you would normally test this like so +As an example, you would normally test this like so: + ```powershell -if(-not $data.user -or -not $data.user.name -or -not $data.user.profile -or -not $data.user.profile.age) { +if(-not $data.user -or -not $data.user.name -or [string]::IsNullOrWhitespace($data.user.name) -or -not $data.user.profile -or -not $data.user.profile.age) { # do something when any of these fields do not exist } -# vs. PSFluentObjectValidation -if(-not (exists -with "user.name" -in $data) -or -not (exists -with "user.profile.age" -in $data)) { +``` + +vs. PSFluentObjectValidation + +```powershell +if(-not (Test-Exist -With "user.name!" -In $data) -or -not (Test-Exist -With "user.profile.age" -In $data)) { # do something when any of these fields do not exist } ``` @@ -57,33 +60,33 @@ if(-not (exists -with "user.name" -in $data) -or -not (exists -with "user.profil ### Validation Suffixes ```powershell -exists -with "user.email!" -in $data # Non-empty validation -exists -with "user.profile?" -in $data # Object existence -exists -with "settings.theme!" -in $data # Non-empty string +Test-Exist -With "user.email!" -In $data # Non-empty validation +Test-Exist -With "user.profile?" -In $data # Object existence +Test-Exist -With "settings.theme!" -In $data # Non-empty string ``` ### Array Indexing ```powershell -exists -with "users[0].name" -in $data # First element -exists -with "users[1].email" -in $data # Second element -exists -with "products[2].title" -in $data # Third element +Test-Exist -With "users[0].name" -In $data # First element +Test-Exist -With "users[1].email" -In $data # Second element +Test-Exist -With "products[2].title" -In $data # Third element ``` ### Wildcard Array Validation ```powershell -exists -with "users[*].name" -in $data # All users have names -exists -with "users[*].email!" -in $data # All users have non-empty emails -exists -with "products[*].active" -in $data # All products have active property +Test-Exist -With "users[*].name" -In $data # All users have names +Test-Exist -With "users[*].email!" -In $data # All users have non-empty emails +Test-Exist -With "products[*].active" -In $data # All products have active property ``` ### Advanced Combinations ```powershell -exists -with "users[0].profile.settings.theme!" -in $data # Deep + validation -exists -with "products[*].category.name!" -in $data # Wildcard + deep + validation -exists -with "orders[1].items[*].price" -in $data # Nested array access +Test-Exist -With "users[0].profile.settings.theme!" -In $data # Deep + validation +Test-Exist -With "products[*].category.name!" -In $data # Wildcard + deep + validation +Test-Exist -With "orders[1].items[*].price" -In $data # Nested array access ``` ## Usage Examples @@ -105,13 +108,13 @@ $apiResponse = @{ } # Validate all users have required fields -exists -in $apiResponse -with "users[*].id" # All users have IDs -exists -in $apiResponse -with "users[*].name!" # All users have non-empty names -exists -in $apiResponse -with "users[*].email!" # All users have non-empty emails +Test-Exist -In $apiResponse -With "users[*].id" # All users have IDs +Test-Exist -In $apiResponse -With "users[*].name!" # All users have non-empty names +Test-Exist -In $apiResponse -With "users[*].email!" # All users have non-empty emails # Validate specific user data -exists -in $apiResponse -with "users[0].active" # First user has active status -exists -in $apiResponse -with "metadata.total!" # Metadata has non-empty total +Test-Exist -In $apiResponse -With "users[0].active" # First user has active status +Test-Exist -In $apiResponse -With "metadata.total!" # Metadata has non-empty total ``` #### Configuration Validation @@ -133,10 +136,10 @@ $config = @{ } # Validate critical configuration -exists -in $config -with "database.host!" # Non-empty host -exists -in $config -with "database.credentials.password!" # Non-empty password -exists -in $config -with "servers[*].name!" # All servers have names -exists -in $config -with "servers[*].ip!" # All servers have IPs +Test-Exist -In $config -With "database.host!" # Non-empty host +Test-Exist -In $config -With "database.credentials.password!" # Non-empty password +Test-Exist -In $config -With "servers[*].name!" # All servers have names +Test-Exist -In $config -With "servers[*].ip!" # All servers have IPs ``` #### E-commerce Data Validation @@ -155,11 +158,11 @@ $order = @{ } # Comprehensive order validation -exists -in $order -with "id!" # Order has ID -exists -in $order -with "customer.email!" # Customer has email -exists -in $order -with "items[*].sku!" # All items have SKUs -exists -in $order -with "items[*].price" # All items have prices -exists -in $order -with "items[0].quantity" # First item has quantity +Test-Exist -In $order -With "id!" # Order has ID +Test-Exist -In $order -With "customer.email!" # Customer has email +Test-Exist -In $order -With "items[*].sku!" # All items have SKUs +Test-Exist -In $order -With "items[*].price" # All items have prices +Test-Exist -In $order -With "items[0].quantity" # First item has quantity ``` ## Error Handling & Edge Cases @@ -170,108 +173,43 @@ exists -in $order -with "items[0].quantity" # First item has quantity ```powershell # Array bounds checking -tests -in $data -with "users[10].name" # Returns false for out-of-bounds -asserts -in $data -with "users[10].name" # Throws: "Array index [10] is out of bounds" +Test-Exist -In $data -With "users[10].name" # Returns false for out-of-bounds +Assert-Exist -In $data -With "users[10].name" # Throws: "Array index [10] is out of bounds" # Null safety -tests -in $data -with "user.profile.settings" # Handles null intermediate objects -asserts -in $data -with "missing.property" # Throws: "Property 'missing' does not exist" +Test-Exist -In $data -With "user.profile.settings" # Handles null intermediate objects +Assert-Exist -In $data -With "missing.property" # Throws: "Property 'missing' does not exist" # Type validation -tests -in $data -with "config.port[0]" # Throws: "Property 'port' is not an array" +Test-Exist -In $data -With "config.port[0]" # Throws: "Property 'port' is not an array" ``` #### Wildcard Validation Error Handling ```powershell # Empty array validation -Test-Exists @{ users = @() } "users[*].name" # Throws: "Array 'users' is empty" +Test-Exist -In @{ users = @() } -With "users[*].name" # Throws: "Array 'users' is empty" # Partial validation failures -Test-Exists $data "users[*].email!" # Validates ALL users have non-empty emails -Assert-Exists $data "users[*].phone!" # Throws if ANY user lacks phone +Test-Exist -In $data -With "users[*].email!" # Validates ALL users have non-empty emails +Assert-Exist -In $data -With "users[*].phone!" # Throws if ANY user lacks phone ``` ### Function Reference -#### `Test-Exists` or `tests` or `exists` - -**Purpose**: Safely test property existence and validation -**Syntax**: `Test-Exists $object $propertyPath` -**Returns**: `$true` if validation passes, `$false` otherwise -**Error Handling**: Never throws exceptions - -#### `Assert-Exists` or `asserts` - -**Purpose**: Assert property existence with detailed error reporting -**Syntax**: `Assert-Exists $object $propertyPath` -**Returns**: `void` (throws on failure) -**Error Handling**: Throws descriptive exceptions for debugging - -### Advanced Configuration - -#### Performance Tuning - -- **Warmup Iterations**: Recommended 1000+ for consistent measurements -- **Test Iterations**: 10,000+ for statistical significance -- **Memory Management**: Automatic garbage collection handling - -#### Debugging Support - -- **Verbose Error Messages**: Detailed exception information -- **Stack Trace Preservation**: Full error context maintenance -- **Development Mode**: Additional validation checks available - -## Future Roadmap - -### Potential Enhancements - -1. **Dynamic Array Slicing**: `users[1:3].name` syntax -2. **Conditional Validation**: `users[active=true].email!` filtering -3. **Performance Profiling**: Built-in benchmarking tools -4. **Visual Studio Code Extension**: IntelliSense support for validation syntax - -### Performance Targets - -- **Sub-10μs Operations**: Further C# optimization -- **Parallel Validation**: Multi-threaded wildcard processing -- **Memory Optimization**: Zero-allocation validation paths - -## Performance Results Overview - -### Performance Comparison - -Originally this module was written in pure powershell but comparing it against just standard manual testing showed it was very slow (in micro seconds, still fairly quick overall). The the module was rewritten to use a different format for testing, which improved performance but it was still relatively slow compared to just manual validation. The module was then implemented in C# and saw significate performance improvements over even manual processing. The module was updated a 4th time to handle arrays as there are times when its better to test before looping with | ForEach-Object. Below is a series of tests written against all 5 methods. - -Based on 10,000 iterations with corrected V3-compatible scenario testing: - -| Version | Average Performance | vs Manual | Technology | Array Support | -|---------|-------------------|-----------|------------|---------------| -| **Manual Validation** | 59.68 μs | ±0% | Native PowerShell | Limited | -| **V1 (PowerShell)** | 352.48 μs | +491% | Pure PowerShell | ❌ | -| **V2 (PowerShell)** | 318.71 μs | +434% | Pure PowerShell | ❌ | -| **V3 (C# Basic)** | **13.06 μs** | **-78.1%** | C# Compiled | ❌ | -| **V4 (C# Current)** | **13.86 μs** | **-76.8%** | C# + Array Logic | ✅ | - -### Performance Highlights +#### `Test-Exist` -- **V3**: **78.1% faster** than manual validation - C# performance breakthrough -- **V4**: **76.8% faster** than manual validation with revolutionary array features -- **V4 vs V3**: Only **6.1% slower** - excellent trade-off for massive feature expansion -- **Both C# versions**: **27x faster** than V1, **23x faster** than V2 +> **Purpose Safely**: test property existence and validation
+> **Syntax**: `Test-Exist -In $object -With $propertyPath`
+> **Returns**: `$true` if validation passes, `$false` otherwise
+> **Error Handling**: Never throws exceptions
-## Features & Capabilities Matrix +#### `Assert-Exist` -| Feature | V1 | V2 | V3 | V4 | Description | -|---------|----|----|----|----|-------------| -| **Basic Properties** | ✅ | ✅ | ✅ | ✅ | `user.name`, `config.host` | -| **Validation Suffixes** | ✅ | ✅ | ✅ | ✅ | `property!` (non-empty), `property?` (exists) | -| **Nested Navigation** | ✅ | ✅ | ✅ | ✅ | `user.profile.settings.theme` | -| **Array Indexing** | ❌ | ❌ | ❌ | ✅ | `users[0].name`, `products[1].title` | -| **Wildcard Arrays** | ❌ | ❌ | ❌ | ✅ | `users[*].name` (all elements) | -| **Array + Validation** | ❌ | ❌ | ❌ | ✅ | `users[*].email!` (all non-empty) | -| **Deep Array Access** | ❌ | ❌ | ❌ | ✅ | `products[0].category.name` | -| **Error Handling** | Basic | Basic | Enhanced | Advanced | Bounds checking, null safety | +> **Purpose**: Assert property existence with detailed error reporting
+> **Syntax**: `Assert-Exist -In $object -With $propertyPath`
+> **Returns**: `void` (throws on failure)
+> **Error Handling**: Throws descriptive exceptions for debugging
[github-actions-badge]: https://img.shields.io/github/actions/workflow/status/pwshdevs/PSFluentObjectValidation/CI.yaml?label=build&style=for-the-badge [github-actions-badge-publish]: https://img.shields.io/github/actions/workflow/status/pwshdevs/PSFluentObjectValidation/publish.yaml?label=publish&style=for-the-badge @@ -285,3 +223,6 @@ Based on 10,000 iterations with corrected V3-compatible scenario testing: [github-closed-issues-badge]: https://img.shields.io/github/issues-closed/pwshdevs/PSFluentObjectValidation?style=for-the-badge [github-closed-issues]: https://github.com/pwshdevs/PSFluentObjectValidation/issues?q=is%3Aissue%20state%3Aclosed [github-open-issues]: https://github.com/pwshdevs/PSFluentObjectValidation/issues +[psgallery-platform-badge]: https://img.shields.io/powershellgallery/p/PSFluentObjectValidation?style=for-the-badge +[ps-desktop-badge]: https://img.shields.io/badge/powershell-5.1,_7.0+-blue?style=for-the-badge +[ps-core-badge]: https://img.shields.io/badge/powershell-5.1,_7.0+-blue?style=for-the-badge diff --git a/docs/en-US/Assert-Exist.md b/docs/en-US/Assert-Exist.md new file mode 100644 index 0000000..c162cdd --- /dev/null +++ b/docs/en-US/Assert-Exist.md @@ -0,0 +1,114 @@ +--- +external help file: PSFluentObjectValidation-help.xml +Module Name: PSFluentObjectValidation +online version: https://www.pwshdevs.com/ +schema: 2.0.0 +--- + +# Assert-Exist + +## SYNOPSIS +Asserts the existence and validity of a property within an object. + +## SYNTAX + +``` +Assert-Exist [-InputObject] [-Key] [-ProgressAction ] [] +``` + +## 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. + +## EXAMPLES + +### EXAMPLE 1 +``` +Assert-Exist -InputObject $data -Key "user.name!" +``` + +Asserts that the \`user.name\` property exists and is non-empty + +### EXAMPLE 2 +``` +Assert-Exist -InputObject $data -Key "users[*].email!" +``` + +Asserts that all users in the array have a non-empty email + +### EXAMPLE 3 +``` +Assert-Exist -InputObject $data -Key "settings.theme" +``` + +Asserts that the \`settings.theme\` property exists + +## PARAMETERS + +### -InputObject +The object to validate. +This can be a hashtable, PSObject, .NET object, or any other object type. + +```yaml +Type: Object +Parameter Sets: (All) +Aliases: In + +Required: True +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -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) + +```yaml +Type: String +Parameter Sets: (All) +Aliases: With, Test + +Required: True +Position: 2 +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Throws an exception if the validation fails. +Use \`Test-Exist\` for a non-throwing alternative. + +## RELATED LINKS + +[https://www.pwshdevs.com/](https://www.pwshdevs.com/) + diff --git a/docs/en-US/Test-Exist.md b/docs/en-US/Test-Exist.md new file mode 100644 index 0000000..6f3bf81 --- /dev/null +++ b/docs/en-US/Test-Exist.md @@ -0,0 +1,114 @@ +--- +external help file: PSFluentObjectValidation-help.xml +Module Name: PSFluentObjectValidation +online version: https://www.pwshdevs.com/ +schema: 2.0.0 +--- + +# Test-Exist + +## SYNOPSIS +Tests the existence and validity of a property within an object. + +## SYNTAX + +``` +Test-Exist [-InputObject] [-Key] [-ProgressAction ] [] +``` + +## DESCRIPTION +The \`Test-Exist\` function validates the existence of a property within an object and ensures it meets the specified validation criteria. +Unlike \`Assert-Exist\`, this function does not throw exceptions; instead, it returns a boolean value indicating whether the validation passed. + +## EXAMPLES + +### EXAMPLE 1 +``` +Test-Exist -InputObject $data -Key "user.name!" +``` + +Tests that the \`user.name\` property exists and is non-empty. + +### EXAMPLE 2 +``` +Test-Exist -InputObject $data -Key "users[*].email!" +``` + +Tests that all users in the array have a non-empty email. + +### EXAMPLE 3 +``` +Test-Exist -InputObject $data -Key "settings.theme" +``` + +Tests that the \`settings.theme\` property exists. + +## PARAMETERS + +### -InputObject +The object to validate. +This can be a hashtable, PSObject, .NET object, or any other object type. + +```yaml +Type: Object +Parameter Sets: (All) +Aliases: In + +Required: True +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -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) + +```yaml +Type: String +Parameter Sets: (All) +Aliases: With, Test + +Required: True +Position: 2 +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Returns \`$true\` if the validation passes, \`$false\` otherwise. +Use \`Assert-Exist\` for a throwing alternative. + +## RELATED LINKS + +[https://www.pwshdevs.com/](https://www.pwshdevs.com/) + diff --git a/docs/en-US/about_PSFluentObjectValidation.help.md b/docs/en-US/about_PSFluentObjectValidation.help.md deleted file mode 100644 index 4a896a5..0000000 --- a/docs/en-US/about_PSFluentObjectValidation.help.md +++ /dev/null @@ -1,59 +0,0 @@ -# PSFluentObjectValidation - -## about_PSFluentObjectValidation - -``` -ABOUT TOPIC NOTE: -The first header of the about topic should be the topic name. -The second header contains the lookup name used by the help system. - -IE: -# Some Help Topic Name -## SomeHelpTopicFileName - -This will be transformed into the text file -as `about_SomeHelpTopicFileName`. -Do not include file extensions. -The second header should have no spaces. -``` - -# SHORT DESCRIPTION -{{ Short Description Placeholder }} - -``` -ABOUT TOPIC NOTE: -About topics can be no longer than 80 characters wide when rendered to text. -Any topics greater than 80 characters will be automatically wrapped. -The generated about topic will be encoded UTF-8. -``` - -# LONG DESCRIPTION -{{ Long Description Placeholder }} - -## Optional Subtopics -{{ Optional Subtopic Placeholder }} - -# EXAMPLES -{{ Code or descriptive examples of how to leverage the functions described. }} - -# NOTE -{{ Note Placeholder - Additional information that a user needs to know.}} - -# TROUBLESHOOTING NOTE -{{ Troubleshooting Placeholder - Warns users of bugs}} - -{{ Explains behavior that is likely to change with fixes }} - -# SEE ALSO -{{ See also placeholder }} - -{{ You can also list related articles, blogs, and video URLs. }} - -# KEYWORDS -{{List alternate names or titles for this topic that readers might use.}} - -- {{ Keyword Placeholder }} -- {{ Keyword Placeholder }} -- {{ Keyword Placeholder }} -- {{ Keyword Placeholder }} - diff --git a/tests/Help.tests.old.ps1 b/tests/Help.tests.old.ps1 new file mode 100644 index 0000000..8a6e175 --- /dev/null +++ b/tests/Help.tests.old.ps1 @@ -0,0 +1,117 @@ +# Taken with love from @juneb_get_help (https://raw.githubusercontent.com/juneb/PesterTDD/master/Module.Help.Tests.ps1) + +BeforeDiscovery { + + function global:FilterOutCommonParams { + param ($Params) + $commonParams = @( + 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', + 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', + 'WarningVariable', 'Confirm', 'Whatif' + ) + $params | Where-Object { $_.Name -notin $commonParams } | Sort-Object -Property Name -Unique + } + + $manifest = Import-PowerShellDataFile -Path $env:BHPSModuleManifest + $outputDir = Join-Path -Path $env:BHProjectPath -ChildPath 'Output' + $outputModDir = Join-Path -Path $outputDir -ChildPath $env:BHProjectName + $outputModVerDir = Join-Path -Path $outputModDir -ChildPath $manifest.ModuleVersion + $outputModVerManifest = Join-Path -Path $outputModVerDir -ChildPath "$($env:BHProjectName).psd1" + + # Get module commands + # Remove all versions of the module from the session. Pester can't handle multiple versions. + Get-Module $env:BHProjectName | Remove-Module -Force -ErrorAction Ignore + Import-Module -Name $outputModVerManifest -Verbose:$false -ErrorAction Stop + $params = @{ + Module = (Get-Module $env:BHProjectName) + CommandType = [System.Management.Automation.CommandTypes[]]'Cmdlet, Function' # Not alias + } + if ($PSVersionTable.PSVersion.Major -lt 6) { + $params.CommandType[0] += 'Workflow' + } + $commands = Get-Command @params + + ## When testing help, remember that help is cached at the beginning of each session. + ## To test, restart session. +} + +Describe "Test help for <_.Name>" -ForEach $commands { + + BeforeDiscovery { + # Get command help, parameters, and links + $command = $_ + $commandHelp = Get-Help $command.Name -ErrorAction SilentlyContinue + $commandParameters = global:FilterOutCommonParams -Params $command.ParameterSets.Parameters + $commandParameterNames = $commandParameters.Name + $helpLinks = $commandHelp.relatedLinks.navigationLink.uri + } + + BeforeAll { + # These vars are needed in both discovery and test phases so we need to duplicate them here + $command = $_ + $commandName = $_.Name + $commandHelp = Get-Help $command.Name -ErrorAction SilentlyContinue + $commandParameters = global:FilterOutCommonParams -Params $command.ParameterSets.Parameters + $commandParameterNames = $commandParameters.Name + $helpParameters = global:FilterOutCommonParams -Params $commandHelp.Parameters.Parameter + $helpParameterNames = $helpParameters.Name + } + + # If help is not found, synopsis in auto-generated help is the syntax diagram + It 'Help is not auto-generated' { + $commandHelp.Synopsis | Should -Not -BeLike '*`[``]*' + } + + # Should be a description for every function + It "Has description" { + $commandHelp.Description | Should -Not -BeNullOrEmpty + } + + # Should be at least one example + It "Has example code" { + ($commandHelp.Examples.Example | Select-Object -First 1).Code | Should -Not -BeNullOrEmpty + } + + # Should be at least one example description + It "Has example help" { + ($commandHelp.Examples.Example.Remarks | Select-Object -First 1).Text | Should -Not -BeNullOrEmpty + } + + It "Help link <_> is valid" -ForEach $helpLinks { + (Invoke-WebRequest -Uri $_ -UseBasicParsing).StatusCode | Should -Be '200' + } + + Context "Parameter <_.Name>" -Foreach $commandParameters { + + BeforeAll { + $parameter = $_ + $parameterName = $parameter.Name + $parameterHelp = $commandHelp.parameters.parameter | Where-Object Name -eq $parameterName + $parameterHelpType = if ($parameterHelp.ParameterValue) { $parameterHelp.ParameterValue.Trim() } + } + + # Should be a description for every parameter + It "Has description" { + $parameterHelp.Description.Text | Should -Not -BeNullOrEmpty + } + + # Required value in Help should match IsMandatory property of parameter + It "Has correct [mandatory] value" { + $codeMandatory = $_.IsMandatory.toString() + $parameterHelp.Required | Should -Be $codeMandatory + } + + # Parameter type in help should match code + It "Has correct parameter type" { + $parameterHelpType | Should -Be $parameter.ParameterType.Name + } + } + + Context "Test <_> help parameter help for " -Foreach $helpParameterNames { + + # Shouldn't find extra parameters in help. + It "finds help parameter in code: <_>" { + $_ -in $parameterNames | Should -Be $true + } + } +} diff --git a/tests/Help.tests.ps1 b/tests/Help.tests.ps1 index 8a6e175..2bca45e 100644 --- a/tests/Help.tests.ps1 +++ b/tests/Help.tests.ps1 @@ -1,14 +1,10 @@ # Taken with love from @juneb_get_help (https://raw.githubusercontent.com/juneb/PesterTDD/master/Module.Help.Tests.ps1) BeforeDiscovery { - function global:FilterOutCommonParams { param ($Params) - $commonParams = @( - 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', - 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', - 'WarningVariable', 'Confirm', 'Whatif' - ) + $commonParams = [System.Management.Automation.PSCmdlet]::OptionalCommonParameters + + [System.Management.Automation.PSCmdlet]::CommonParameters $params | Where-Object { $_.Name -notin $commonParams } | Sort-Object -Property Name -Unique } @@ -16,12 +12,12 @@ BeforeDiscovery { $outputDir = Join-Path -Path $env:BHProjectPath -ChildPath 'Output' $outputModDir = Join-Path -Path $outputDir -ChildPath $env:BHProjectName $outputModVerDir = Join-Path -Path $outputModDir -ChildPath $manifest.ModuleVersion - $outputModVerManifest = Join-Path -Path $outputModVerDir -ChildPath "$($env:BHProjectName).psd1" + $global:outputModVerManifest = Join-Path -Path $outputModVerDir -ChildPath "$($env:BHProjectName).psd1" # Get module commands # Remove all versions of the module from the session. Pester can't handle multiple versions. Get-Module $env:BHProjectName | Remove-Module -Force -ErrorAction Ignore - Import-Module -Name $outputModVerManifest -Verbose:$false -ErrorAction Stop + Import-Module -Name $global:outputModVerManifest -Verbose:$false -ErrorAction Stop -Force $params = @{ Module = (Get-Module $env:BHProjectName) CommandType = [System.Management.Automation.CommandTypes[]]'Cmdlet, Function' # Not alias @@ -31,30 +27,39 @@ BeforeDiscovery { } $commands = Get-Command @params + Get-Module $env:BHProjectName | Remove-Module -Force -ErrorAction Ignore ## When testing help, remember that help is cached at the beginning of each session. ## To test, restart session. } +AfterAll { + Remove-Item Function:/FilterOutCommonParams +} + Describe "Test help for <_.Name>" -ForEach $commands { BeforeDiscovery { # Get command help, parameters, and links + Import-Module -Name $global:outputModVerManifest -Verbose:$false -ErrorAction Stop -Force $command = $_ - $commandHelp = Get-Help $command.Name -ErrorAction SilentlyContinue + $commandHelp = Get-Help "$($env:BHProjectName)\$($command.Name)" -ErrorAction SilentlyContinue $commandParameters = global:FilterOutCommonParams -Params $command.ParameterSets.Parameters $commandParameterNames = $commandParameters.Name $helpLinks = $commandHelp.relatedLinks.navigationLink.uri + Get-Module $env:BHProjectName | Remove-Module -Force -ErrorAction Ignore } BeforeAll { # These vars are needed in both discovery and test phases so we need to duplicate them here + Import-Module -Name $global:outputModVerManifest -Verbose:$false -ErrorAction Stop -Force $command = $_ $commandName = $_.Name - $commandHelp = Get-Help $command.Name -ErrorAction SilentlyContinue + $commandHelp = Get-Help "$($env:BHProjectName)\$($command.Name)" -ErrorAction SilentlyContinue $commandParameters = global:FilterOutCommonParams -Params $command.ParameterSets.Parameters $commandParameterNames = $commandParameters.Name $helpParameters = global:FilterOutCommonParams -Params $commandHelp.Parameters.Parameter $helpParameterNames = $helpParameters.Name + Get-Module $env:BHProjectName | Remove-Module -Force -ErrorAction Ignore } # If help is not found, synopsis in auto-generated help is the syntax diagram diff --git a/tests/PSFluentObjectValidation.tests.ps1 b/tests/PSFluentObjectValidation.tests.ps1 new file mode 100644 index 0000000..fb0a1a7 --- /dev/null +++ b/tests/PSFluentObjectValidation.tests.ps1 @@ -0,0 +1,505 @@ +BeforeAll { + # Remove any existing module from session to avoid conflicts + if (Get-Module PSFluentObjectValidation) { + Remove-Module PSFluentObjectValidation -Force + } + + # Import the module from the build output directory (if it exists) or development directory + $OutputModulePath = "$PSScriptRoot\..\Output\PSFluentObjectValidation\1.0.2\PSFluentObjectValidation.psd1" + $DevModulePath = "$PSScriptRoot\..\PSFluentObjectValidation\PSFluentObjectValidation.psd1" + + if (Test-Path $OutputModulePath) { + Import-Module $OutputModulePath -Force + } elseif (Test-Path $DevModulePath) { + Import-Module $DevModulePath -Force + } else { + throw "Could not find PSFluentObjectValidation module at expected paths" + } +} + +Describe 'PSFluentObjectValidation Module Tests' { + + BeforeAll { + # Test data structures for comprehensive validation + $script:TestData = @{ + # Simple properties + name = "John Doe" + age = 30 + active = $true + + # Null and empty values + nullValue = $null + emptyString = "" + whitespaceString = " " + emptyArray = @() + + # Nested objects + user = @{ + profile = @{ + email = "john@example.com" + settings = @{ + theme = "dark" + notifications = $true + } + } + preferences = @{ + language = "en" + timezone = $null + } + } + + # Arrays with objects + users = @( + @{ id = 1; name = "Alice"; email = "alice@test.com"; active = $true } + @{ id = 2; name = "Bob"; email = "bob@test.com"; active = $false } + @{ id = 3; name = "Charlie"; email = ""; active = $true } + @{ id = 4; name = ""; email = "diana@test.com"; active = $true } + ) + + # Mixed array types + products = @( + @{ + id = 1 + title = "Laptop" + category = @{ name = "Electronics"; active = $true } + tags = @("computer", "portable") + price = 999.99 + } + @{ + id = 2 + title = "Mouse" + category = @{ name = "Accessories"; active = $true } + tags = @("peripheral") + price = 29.99 + } + ) + + # Complex nested structure + orders = @( + @{ + id = "ORD-001" + customer = @{ + name = "John Smith" + email = "john.smith@example.com" + } + items = @( + @{ sku = "LAPTOP-001"; quantity = 1; price = 999.99 } + @{ sku = "MOUSE-001"; quantity = 2; price = 29.99 } + ) + metadata = @{ + source = "web" + campaign = $null + } + } + @{ + id = "ORD-002" + customer = @{ + name = "Jane Doe" + email = "jane@example.com" + } + items = @( + @{ sku = "KEYBOARD-001"; quantity = 1; price = 79.99 } + ) + metadata = @{ + source = "mobile" + campaign = "SUMMER2025" + } + } + ) + + # Empty nested structures + emptyStructure = @{ + emptyArray = @() + emptyObject = @{} + nullArray = $null + } + } + + # PSObject test data + $script:PSObjectData = New-Object PSObject -Property @{ + name = "PSObject Test" + nested = New-Object PSObject -Property @{ + value = 42 + array = @(1, 2, 3) + } + } + + # .NET object test data + $script:NetObjectData = [PSCustomObject]@{ + Property1 = "Value1" + Property2 = [PSCustomObject]@{ + SubProperty = "SubValue" + } + } + } + + Context 'Test-Exist Function Tests' { + + Describe 'Basic Property Access' { + + It 'Should return true for existing simple properties' { + Test-Exist -In $TestData -With "name" | Should -Be $true + Test-Exist -In $TestData -With "age" | Should -Be $true + Test-Exist -In $TestData -With "active" | Should -Be $true + } + + It 'Should return false for non-existing properties' { + Test-Exist -In $TestData -With "nonexistent" | Should -Be $false + Test-Exist -In $TestData -With "missing.property" | Should -Be $false + } + + It 'Should handle null values gracefully' { + Test-Exist -In $TestData -With "nullValue" | Should -Be $true + } + + It 'Should work with nested properties' { + Test-Exist -In $TestData -With "user.profile.email" | Should -Be $true + Test-Exist -In $TestData -With "user.profile.settings.theme" | Should -Be $true + Test-Exist -In $TestData -With "user.preferences.language" | Should -Be $true + } + + It 'Should return false for missing nested properties' { + Test-Exist -In $TestData -With "user.profile.missing" | Should -Be $false + Test-Exist -In $TestData -With "user.missing.property" | Should -Be $false + Test-Exist -In $TestData -With "missing.user.property" | Should -Be $false + } + } + + Describe 'Validation Operators' { + + Context 'Non-empty validation (!)' { + + It 'Should return true for non-empty values' { + Test-Exist -In $TestData -With "name!" | Should -Be $true + Test-Exist -In $TestData -With "user.profile.email!" | Should -Be $true + Test-Exist -In $TestData -With "user.profile.settings.theme!" | Should -Be $true + } + + It 'Should return false for null values' { + Test-Exist -In $TestData -With "nullValue!" | Should -Be $false + Test-Exist -In $TestData -With "user.preferences.timezone!" | Should -Be $false + } + + It 'Should return false for empty strings' { + Test-Exist -In $TestData -With "emptyString!" | Should -Be $false + Test-Exist -In $TestData -With "whitespaceString!" | Should -Be $false + } + + It 'Should return false for empty arrays' { + Test-Exist -In $TestData -With "emptyArray!" | Should -Be $false + Test-Exist -In $TestData -With "emptyStructure.emptyArray!" | Should -Be $false + } + + It 'Should return false for non-existing properties' { + Test-Exist -In $TestData -With "nonexistent!" | Should -Be $false + } + } + + Context 'Existence validation (?)' { + + It 'Should return true for existing properties even if null' { + Test-Exist -In $TestData -With "nullValue?" | Should -Be $true + Test-Exist -In $TestData -With "user.preferences.timezone?" | Should -Be $true + } + + It 'Should return true for non-empty values' { + Test-Exist -In $TestData -With "name?" | Should -Be $true + Test-Exist -In $TestData -With "user.profile.email?" | Should -Be $true + } + + It 'Should return false for empty arrays' { + Test-Exist -In $TestData -With "emptyArray?" | Should -Be $false + Test-Exist -In $TestData -With "emptyStructure.emptyArray?" | Should -Be $false + } + + It 'Should return false for non-existing properties' { + Test-Exist -In $TestData -With "nonexistent?" | Should -Be $false + } + } + } + + Describe 'Array Indexing' { + + It 'Should access array elements by index' { + Test-Exist -In $TestData -With "users[0]" | Should -Be $true + Test-Exist -In $TestData -With "users[1]" | Should -Be $true + Test-Exist -In $TestData -With "users[2]" | Should -Be $true + Test-Exist -In $TestData -With "users[3]" | Should -Be $true + } + + It 'Should access properties of array elements' { + Test-Exist -In $TestData -With "users[0].name" | Should -Be $true + Test-Exist -In $TestData -With "users[1].email" | Should -Be $true + Test-Exist -In $TestData -With "users[0].id" | Should -Be $true + } + + It 'Should return false for out-of-bounds indices' { + Test-Exist -In $TestData -With "users[10]" | Should -Be $false + Test-Exist -In $TestData -With "users[100].name" | Should -Be $false + } + + It 'Should handle negative indices gracefully' { + Test-Exist -In $TestData -With "users[-1]" | Should -Be $false + } + + It 'Should work with nested array access' { + Test-Exist -In $TestData -With "orders[0].items[0]" | Should -Be $true + Test-Exist -In $TestData -With "orders[0].items[0].sku" | Should -Be $true + Test-Exist -In $TestData -With "orders[1].items[0].price" | Should -Be $true + } + } + + Describe 'Wildcard Array Validation' { + + It 'Should validate properties exist on all array elements' { + Test-Exist -In $TestData -With "users[*].id" | Should -Be $true + Test-Exist -In $TestData -With "users[*].name" | Should -Be $true + Test-Exist -In $TestData -With "users[*].active" | Should -Be $true + } + + It 'Should return false if any element is missing the property' { + Test-Exist -In $TestData -With "users[*].missing" | Should -Be $false + } + + It 'Should work with wildcard validation operators' { + Test-Exist -In $TestData -With "users[*].id!" | Should -Be $true + Test-Exist -In $TestData -With "users[*].email!" | Should -Be $false # One user has empty email + Test-Exist -In $TestData -With "users[*].name!" | Should -Be $false # One user has empty name + } + + It 'Should work with nested wildcard validation' { + Test-Exist -In $TestData -With "products[*].category.name" | Should -Be $true + Test-Exist -In $TestData -With "products[*].category.name!" | Should -Be $true + Test-Exist -In $TestData -With "orders[*].customer.email!" | Should -Be $true + } + + It 'Should handle empty arrays' { + Test-Exist -In $TestData -With "emptyArray[*].property" | Should -Be $false + Test-Exist -In $TestData -With "emptyStructure.emptyArray[*].property" | Should -Be $false + } + } + + Describe 'Complex Combinations' { + + It 'Should handle deep nesting with validation' { + Test-Exist -In $TestData -With "user.profile.settings.theme!" | Should -Be $true + Test-Exist -In $TestData -With "orders[0].customer.email!" | Should -Be $true + Test-Exist -In $TestData -With "products[0].category.active?" | Should -Be $true + } + + It 'Should handle mixed indexing and wildcards' { + Test-Exist -In $TestData -With "orders[0].items[*].sku" | Should -Be $true + Test-Exist -In $TestData -With "orders[1].items[*].price!" | Should -Be $true + # Fixed: Complex wildcard+indexing patterns now work + Test-Exist -In $TestData -With "orders[*].items[0].quantity" | Should -Be $true + } + + It 'Should validate complex nested structures' { + Test-Exist -In $TestData -With "orders[*].customer.name!" | Should -Be $true + Test-Exist -In $TestData -With "orders[*].metadata.source!" | Should -Be $true + Test-Exist -In $TestData -With "orders[*].metadata.campaign!" | Should -Be $false # One order has null campaign + } + } + + Describe 'Different Object Types' { + + It 'Should work with PSObjects' { + Test-Exist -In $PSObjectData -With "name" | Should -Be $true + Test-Exist -In $PSObjectData -With "nested.value" | Should -Be $true + Test-Exist -In $PSObjectData -With "nested.array[0]" | Should -Be $true + } + + It 'Should work with .NET objects' { + Test-Exist -In $NetObjectData -With "Property1" | Should -Be $true + Test-Exist -In $NetObjectData -With "Property2.SubProperty" | Should -Be $true + } + } + + Describe 'Edge Cases' { + + It 'Should handle null input objects gracefully' { + # PowerShell parameter binding prevents null InputObject, so we test the C# class directly + [PSFluentObjectValidation]::TestExists($null, "property") | Should -Be $false + } + + It 'Should handle empty property paths gracefully' { + # PowerShell parameter binding prevents empty Key, so we test the C# class directly + [PSFluentObjectValidation]::TestExists($TestData, "") | Should -Be $false + } + + It 'Should handle invalid array indices gracefully' { + Test-Exist -In $TestData -With "users[abc]" | Should -Be $false + Test-Exist -In $TestData -With "users[]" | Should -Be $false + } + + It 'Should handle properties that are not arrays when using array syntax' { + Test-Exist -In $TestData -With "name[0]" | Should -Be $false + Test-Exist -In $TestData -With "age[*]" | Should -Be $false + } + } + } + + Context 'Assert-Exist Function Tests' { + + Describe 'Basic Property Access' { + + It 'Should not throw for existing properties' { + { Assert-Exist -In $TestData -With "name" } | Should -Not -Throw + { Assert-Exist -In $TestData -With "user.profile.email" } | Should -Not -Throw + } + + It 'Should throw for non-existing properties' { + { Assert-Exist -In $TestData -With "nonexistent" } | Should -Throw + { Assert-Exist -In $TestData -With "user.profile.missing" } | Should -Throw + } + + It 'Should throw with descriptive error messages for missing properties' { + { Assert-Exist -In $TestData -With "nonexistent" } | Should -Throw -ExpectedMessage "*Property 'nonexistent' does not exist*" + { Assert-Exist -In $TestData -With "user.missing" } | Should -Throw -ExpectedMessage "*Property 'missing' does not exist*" + } + } + + Describe 'Validation Operators' { + + Context 'Non-empty validation (!)' { + + It 'Should not throw for non-empty values' { + { Assert-Exist -In $TestData -With "name!" } | Should -Not -Throw + { Assert-Exist -In $TestData -With "user.profile.email!" } | Should -Not -Throw + } + + It 'Should throw for null values' { + { Assert-Exist -In $TestData -With "nullValue!" } | Should -Throw -ExpectedMessage "*is null*" + { Assert-Exist -In $TestData -With "user.preferences.timezone!" } | Should -Throw -ExpectedMessage "*is null*" + } + + It 'Should throw for empty strings' { + { Assert-Exist -In $TestData -With "emptyString!" } | Should -Throw -ExpectedMessage "*is empty*" + { Assert-Exist -In $TestData -With "whitespaceString!" } | Should -Throw -ExpectedMessage "*is empty or whitespace*" + } + + It 'Should throw for empty arrays' { + { Assert-Exist -In $TestData -With "emptyArray!" } | Should -Throw -ExpectedMessage "*is empty*" + } + } + + Context 'Existence validation (?)' { + + It 'Should not throw for existing properties even if null' { + { Assert-Exist -In $TestData -With "nullValue?" } | Should -Not -Throw + { Assert-Exist -In $TestData -With "user.preferences.timezone?" } | Should -Not -Throw + } + + It 'Should throw for empty arrays' { + { Assert-Exist -In $TestData -With "emptyArray?" } | Should -Throw -ExpectedMessage "*is empty*" + } + + It 'Should throw for non-existing properties' { + { Assert-Exist -In $TestData -With "nonexistent?" } | Should -Throw -ExpectedMessage "*does not exist*" + } + } + } + + Describe 'Array Indexing' { + + It 'Should not throw for valid array indices' { + { Assert-Exist -In $TestData -With "users[0]" } | Should -Not -Throw + { Assert-Exist -In $TestData -With "users[0].name" } | Should -Not -Throw + } + + It 'Should throw for out-of-bounds indices with descriptive messages' { + { Assert-Exist -In $TestData -With "users[10]" } | Should -Throw -ExpectedMessage "*Array index*out of bounds*" + { Assert-Exist -In $TestData -With "users[100]" } | Should -Throw -ExpectedMessage "*Array index*out of bounds*" + } + + It 'Should throw when trying to index non-array properties' { + { Assert-Exist -In $TestData -With "name[0]" } | Should -Throw -ExpectedMessage "*is not an array*" + { Assert-Exist -In $TestData -With "age[*]" } | Should -Throw -ExpectedMessage "*is not an array*" + } + } + + Describe 'Wildcard Array Validation' { + + It 'Should not throw when all elements have the property' { + { Assert-Exist -In $TestData -With "users[*].id" } | Should -Not -Throw + { Assert-Exist -In $TestData -With "products[*].title" } | Should -Not -Throw + } + + It 'Should throw when any element is missing the property' { + { Assert-Exist -In $TestData -With "users[*].missing" } | Should -Throw -ExpectedMessage "*does not have property*" + } + + It 'Should throw with element context for validation failures' { + { Assert-Exist -In $TestData -With "users[*].email!" } | Should -Throw -ExpectedMessage "*element*empty*" + { Assert-Exist -In $TestData -With "users[*].name!" } | Should -Throw -ExpectedMessage "*element*empty*" + } + + It 'Should throw for empty arrays with wildcard' { + { Assert-Exist -In $TestData -With "emptyArray[*].property" } | Should -Throw -ExpectedMessage "*empty*cannot validate*" + } + } + + Describe 'Error Message Quality' { + + It 'Should provide context in error messages for deep nesting' { + { Assert-Exist -In $TestData -With "user.profile.missing.deep" } | Should -Throw -ExpectedMessage "*Property 'missing' does not exist*" + } + + It 'Should provide array context in error messages' { + { Assert-Exist -In $TestData -With "users[0].missing" } | Should -Throw -ExpectedMessage "*Property 'missing' does not exist*" + } + + It 'Should provide validation context in error messages' { + { Assert-Exist -In $TestData -With "orders[0].metadata.campaign!" } | Should -Throw -ExpectedMessage "*is null*" + } + } + + Describe 'Input Validation' { + + It 'Should throw for null input objects' { + # PowerShell parameter binding prevents null, so test C# class directly + { [PSFluentObjectValidation]::AssertExists($null, "property") } | Should -Throw -ExpectedMessage "*InputObject cannot be null*" + } + + It 'Should throw for null or empty keys' { + # PowerShell parameter binding prevents null/empty, so test C# class directly + { [PSFluentObjectValidation]::AssertExists($TestData, $null) } | Should -Throw -ExpectedMessage "*Key cannot be null or empty*" + { [PSFluentObjectValidation]::AssertExists($TestData, "") } | Should -Throw -ExpectedMessage "*Key cannot be null or empty*" + } + } + } + + Context 'Function Aliases' { + + It 'Should support Test-Exist aliases' { + exists -In $TestData -With "name" | Should -Be $true + tests -In $TestData -With "user.profile.email" | Should -Be $true + } + + It 'Should support Assert-Exist aliases' { + { asserts -In $TestData -With "name" } | Should -Not -Throw + } + } + + Context 'Performance and Edge Cases' { + + It 'Should handle deeply nested structures efficiently' { + $deepData = @{ level1 = @{ level2 = @{ level3 = @{ level4 = @{ value = "deep" } } } } } + Test-Exist -In $deepData -With "level1.level2.level3.level4.value" | Should -Be $true + } + + It 'Should handle large arrays efficiently' { + $largeArray = 1..100 | ForEach-Object { @{ id = $_; name = "Item$_" } } + $largeData = @{ items = $largeArray } + Test-Exist -In $largeData -With "items[99].name" | Should -Be $true + Test-Exist -In $largeData -With "items[*].id" | Should -Be $true + } + + It 'Should handle mixed validation patterns' { + Test-Exist -In $TestData -With "orders[0].items[*].sku!" | Should -Be $true + Test-Exist -In $TestData -With "orders[*].customer.name!" | Should -Be $true + # Fixed: Wildcard followed by array indexing now works + Test-Exist -In $TestData -With "products[*].tags[0]" | Should -Be $true + } + } +}