From 0376d2e7e513719de778a86fa30d3aaab4da1edd Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Sun, 28 Dec 2025 02:19:47 +0200 Subject: [PATCH 1/3] Add pscustom object Add a strictly typed, round-trip capable parser Use LoadFile even for typed implementation Implement custom tags Remove commandlet wrappers Flow style and round tripping fixes Signed-off-by: Gabriel Adrian Samfira --- .github/workflows/ci.yaml | 6 +- .gitignore | 7 + README.md | 2 +- Tests/CaseSensitiveKeys.ps1 | 51 + Tests/DeepNestedConvertersClasses.ps1 | 177 +++ Tests/DeepNestingClasses.ps1 | 35 + Tests/MappingStyleClasses.ps1 | 36 + Tests/TaggedScalars.ps1 | 22 + Tests/TypedYamlTestClasses.ps1 | 188 +++ Tests/case-sensitive-keys.Tests.ps1 | 199 ++++ Tests/custom-converters.Tests.ps1 | 205 ++++ Tests/deep-nested-converters.Tests.ps1 | 344 ++++++ Tests/deep-nesting.Tests.ps1 | 206 ++++ Tests/enhanced-pscustomobject.Tests.ps1 | 932 +++++++++++++++ Tests/mapping-style.Tests.ps1 | 305 +++++ Tests/metadata.Tests.ps1 | 128 +++ Tests/powershell-yaml.Tests.ps1 | 144 +++ Tests/roundtrip.Tests.ps1 | 43 + Tests/tagged-scalars.Tests.ps1 | 102 ++ Tests/typed-yaml-metadata.Tests.ps1 | 350 ++++++ Tests/typed-yaml.Tests.ps1 | 724 ++++++++++++ build.ps1 | 189 ++- examples/README.md | 494 ++++++++ examples/advanced-features.ps1 | 93 ++ examples/classes/AdvancedConfig.ps1 | 50 + examples/classes/CustomConverters.ps1 | 150 +++ examples/classes/DemoClasses.ps1 | 37 + examples/classes/DuplicateKeyClasses.ps1 | 42 + examples/classes/ServerConfig.ps1 | 30 + examples/custom-converters.ps1 | 145 +++ examples/duplicate-key-detection.ps1 | 125 ++ examples/metadata-demo.ps1 | 82 ++ examples/typed-yaml-demo.ps1 | 106 ++ examples/yamlkey-attribute.ps1 | 54 + lib/netstandard2.0/LICENSE.txt | 19 - lib/netstandard2.0/PowerShellYaml.Module.dll | Bin 0 -> 30720 bytes lib/netstandard2.0/PowerShellYaml.dll | Bin 0 -> 15360 bytes .../PowerShellYamlSerializer.dll | Bin 11264 -> 0 bytes lib/netstandard2.0/YamlDotNet.dll | Bin powershell-yaml.psd1 | 56 +- powershell-yaml.psm1 | 394 +++++-- powershell-yaml.sln | 8 +- .../LegacySerializers.cs | 1016 +++++++++++++++++ .../PowerShellYaml.Module.csproj | 24 + .../TypedYamlConverter.cs | 642 +++++++++++ src/PowerShellYaml/PowerShellYaml.csproj | 11 + src/PowerShellYaml/YamlBase.cs | 678 +++++++++++ src/PowerShellYaml/YamlPropertyAttribute.cs | 40 + src/PowerShellYamlSerializer.cs | 247 ---- src/PowerShellYamlSerializer.csproj | 13 - 50 files changed, 8588 insertions(+), 363 deletions(-) create mode 100644 Tests/CaseSensitiveKeys.ps1 create mode 100644 Tests/DeepNestedConvertersClasses.ps1 create mode 100644 Tests/DeepNestingClasses.ps1 create mode 100644 Tests/MappingStyleClasses.ps1 create mode 100644 Tests/TaggedScalars.ps1 create mode 100644 Tests/TypedYamlTestClasses.ps1 create mode 100644 Tests/case-sensitive-keys.Tests.ps1 create mode 100644 Tests/custom-converters.Tests.ps1 create mode 100644 Tests/deep-nested-converters.Tests.ps1 create mode 100644 Tests/deep-nesting.Tests.ps1 create mode 100644 Tests/enhanced-pscustomobject.Tests.ps1 create mode 100644 Tests/mapping-style.Tests.ps1 create mode 100644 Tests/metadata.Tests.ps1 create mode 100644 Tests/roundtrip.Tests.ps1 create mode 100644 Tests/tagged-scalars.Tests.ps1 create mode 100644 Tests/typed-yaml-metadata.Tests.ps1 create mode 100644 Tests/typed-yaml.Tests.ps1 create mode 100644 examples/README.md create mode 100644 examples/advanced-features.ps1 create mode 100644 examples/classes/AdvancedConfig.ps1 create mode 100644 examples/classes/CustomConverters.ps1 create mode 100644 examples/classes/DemoClasses.ps1 create mode 100644 examples/classes/DuplicateKeyClasses.ps1 create mode 100644 examples/classes/ServerConfig.ps1 create mode 100644 examples/custom-converters.ps1 create mode 100644 examples/duplicate-key-detection.ps1 create mode 100644 examples/metadata-demo.ps1 create mode 100644 examples/typed-yaml-demo.ps1 create mode 100644 examples/yamlkey-attribute.ps1 delete mode 100644 lib/netstandard2.0/LICENSE.txt create mode 100644 lib/netstandard2.0/PowerShellYaml.Module.dll create mode 100644 lib/netstandard2.0/PowerShellYaml.dll delete mode 100644 lib/netstandard2.0/PowerShellYamlSerializer.dll mode change 100644 => 100755 lib/netstandard2.0/YamlDotNet.dll create mode 100644 src/PowerShellYaml.Module/LegacySerializers.cs create mode 100644 src/PowerShellYaml.Module/PowerShellYaml.Module.csproj create mode 100644 src/PowerShellYaml.Module/TypedYamlConverter.cs create mode 100644 src/PowerShellYaml/PowerShellYaml.csproj create mode 100644 src/PowerShellYaml/YamlBase.cs create mode 100644 src/PowerShellYaml/YamlPropertyAttribute.cs delete mode 100644 src/PowerShellYamlSerializer.cs delete mode 100644 src/PowerShellYamlSerializer.csproj diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d262654..67859ad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest] + os: [windows-2022, windows-latest] steps: - uses: actions/checkout@v3 @@ -40,8 +40,8 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - + os: [macos-latest, ubuntu-latest, windows-2022, windows-latest] + steps: - uses: actions/checkout@v3 - name: Install modules diff --git a/.gitignore b/.gitignore index ae3e075..d4bcd94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ src/obj src/bin +src/*/obj +src/*/bin +src/*/*/obj +src/*/*/bin + +# Test results +TestResults*.xml diff --git a/README.md b/README.md index 501d1e2..5aa1e27 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The ```lib``` folder contains the YamlDotNet assemblies. They are not really req ## Installation -This module is available for installation via [Powershell Gallery](http://www.powershellgallery.com/). Simply run the following command: +This module is available for installation via [Powershell Gallery](http://www.powershellgallery.com/). ```powershell Install-Module powershell-yaml diff --git a/Tests/CaseSensitiveKeys.ps1 b/Tests/CaseSensitiveKeys.ps1 new file mode 100644 index 0000000..4cb099f --- /dev/null +++ b/Tests/CaseSensitiveKeys.ps1 @@ -0,0 +1,51 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Test class demonstrating YamlKey attribute for case-sensitive YAML keys +# PowerShell class properties are case-insensitive, so we need the attribute +# to distinguish between "Test" and "test" in the YAML +class CaseSensitiveTest : YamlBase { + [YamlKey("Test")] + [string]$CapitalizedTest = "" + + [YamlKey("test")] + [int]$LowercaseTest = 0 +} + +# Test class with mixed attribute and auto-conversion +class MixedKeysTest : YamlBase { + # Uses attribute + [YamlKey("custom-key")] + [string]$CustomProperty = "" + + # Uses automatic PascalCase -> hyphenated-case conversion + [int]$AutoConvertedKey = 0 +} + +# Test class that will fail due to duplicate key without explicit mapping +class IWillFailDueToDuplicateKey : YamlBase { + [string]$test = "" +} + +# Test class that succeeds because all duplicate keys are explicitly mapped +class IWillSucceedBecauseIHaveAMappedKey : YamlBase { + [YamlKey("test")] + [string]$test = "" + + [YamlKey("Test")] + [string]$alsoTestButUppercase = "" +} diff --git a/Tests/DeepNestedConvertersClasses.ps1 b/Tests/DeepNestedConvertersClasses.ps1 new file mode 100644 index 0000000..89b1728 --- /dev/null +++ b/Tests/DeepNestedConvertersClasses.ps1 @@ -0,0 +1,177 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Test classes for deep nesting with custom converters + +using namespace PowerShellYaml +using namespace System +using namespace System.Collections.Generic + +# Custom type: IP Address (renamed to avoid conflict with System.Net.IPAddress) +class CustomIPAddress { + [byte]$Octet1 = 0 + [byte]$Octet2 = 0 + [byte]$Octet3 = 0 + [byte]$Octet4 = 0 + + CustomIPAddress() { + $this.Octet1 = 0 + $this.Octet2 = 0 + $this.Octet3 = 0 + $this.Octet4 = 0 + } + + [string] ToString() { + return "$($this.Octet1).$($this.Octet2).$($this.Octet3).$($this.Octet4)" + } +} + +# Custom converter for IP Address +class IPAddressConverter : YamlConverter { + [bool] CanHandle([string]$tag, [Type]$targetType) { + return $targetType -eq [CustomIPAddress] + } + + [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { + $ip = [CustomIPAddress]@{} + + if ($data -is [string]) { + # Parse string format: "192.168.1.1" + $octets = $data -split '\.' + if ($octets.Length -ne 4) { + throw [FormatException]::new("Invalid IP address format: $data") + } + $ip.Octet1 = [byte]$octets[0] + $ip.Octet2 = [byte]$octets[1] + $ip.Octet3 = [byte]$octets[2] + $ip.Octet4 = [byte]$octets[3] + } + elseif ($data -is [System.Collections.Generic.Dictionary[string, object]]) { + # Parse dictionary format + if ($data.ContainsKey('a')) { $ip.Octet1 = [byte]$data['a'] } + if ($data.ContainsKey('b')) { $ip.Octet2 = [byte]$data['b'] } + if ($data.ContainsKey('c')) { $ip.Octet3 = [byte]$data['c'] } + if ($data.ContainsKey('d')) { $ip.Octet4 = [byte]$data['d'] } + } + + return $ip + } + + [object] ConvertToYaml([object]$value) { + $ip = [CustomIPAddress]$value + return @{ + Value = $ip.ToString() + Tag = '!ipaddr' + } + } +} + +# Custom type: Duration (simple time span) +class Duration { + [int]$Hours = 0 + [int]$Minutes = 0 + [int]$Seconds = 0 + + Duration() { + $this.Hours = 0 + $this.Minutes = 0 + $this.Seconds = 0 + } + + [string] ToString() { + return "$($this.Hours)h$($this.Minutes)m$($this.Seconds)s" + } +} + +# Custom converter for Duration +class DurationConverter : YamlConverter { + [bool] CanHandle([string]$tag, [Type]$targetType) { + return $targetType -eq [Duration] + } + + [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { + $duration = [Duration]::new() + + if ($data -is [string]) { + # Parse string format: "2h30m15s" + if ($data -match '^(\d+)h(\d+)m(\d+)s$') { + $duration.Hours = [int]$Matches[1] + $duration.Minutes = [int]$Matches[2] + $duration.Seconds = [int]$Matches[3] + } + else { + throw [FormatException]::new("Invalid duration format: $data") + } + } + elseif ($data -is [System.Collections.Generic.Dictionary[string, object]]) { + if ($data.ContainsKey('hours')) { $duration.Hours = [int]$data['hours'] } + if ($data.ContainsKey('minutes')) { $duration.Minutes = [int]$data['minutes'] } + if ($data.ContainsKey('seconds')) { $duration.Seconds = [int]$data['seconds'] } + } + + return $duration + } + + [object] ConvertToYaml([object]$value) { + $duration = [Duration]$value + return @{ + Value = $duration.ToString() + Tag = '!duration' + } + } +} + +# Level 3: Server configuration (deepest level with converters) +class ServerConfig : YamlBase { + [string]$Hostname = "" + + [YamlConverter("IPAddressConverter")] + [CustomIPAddress]$Address = $null + + [int]$Port = 0 + + [YamlConverter("DurationConverter")] + [Duration]$Timeout = $null +} + +# Level 2: Database configuration (middle level with converters) +class DatabaseConfig : YamlBase { + [string]$Name = "" + + [YamlConverter("IPAddressConverter")] + [CustomIPAddress]$Host = $null + + [int]$Port = 0 + + [ServerConfig]$PrimaryServer = $null + [ServerConfig]$ReplicaServer = $null + + [YamlConverter("DurationConverter")] + [Duration]$ConnectionTimeout = $null +} + +# Level 1: Application configuration (top level) +class ApplicationConfig : YamlBase { + [string]$AppName = "" + [string]$Environment = "" + + [DatabaseConfig]$Database = $null + + [YamlConverter("IPAddressConverter")] + [CustomIPAddress]$ApiGateway = $null + + [YamlConverter("DurationConverter")] + [Duration]$RequestTimeout = $null +} diff --git a/Tests/DeepNestingClasses.ps1 b/Tests/DeepNestingClasses.ps1 new file mode 100644 index 0000000..0fc03ae --- /dev/null +++ b/Tests/DeepNestingClasses.ps1 @@ -0,0 +1,35 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Simple classes using default YamlBase implementations +# No need to implement ToDictionary/FromDictionary! + +class Person : YamlBase { + [string]$Name = "" + [int]$Age = 0 +} + +class Family : YamlBase { + [Person]$Mom = $null + [Person]$Dad = $null + [Person[]]$Children = $null +} + +class MyClass : YamlBase { + [string]$Title = "" + [Family]$Family = $null +} diff --git a/Tests/MappingStyleClasses.ps1 b/Tests/MappingStyleClasses.ps1 new file mode 100644 index 0000000..713b00c --- /dev/null +++ b/Tests/MappingStyleClasses.ps1 @@ -0,0 +1,36 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Test classes for mapping style +class Address : YamlBase { + [string]$Street = "" + [string]$City = "" + [string]$Zip = "" +} + +class Person : YamlBase { + [string]$Name = "" + [int]$Age = 0 + [Address]$Address = $null +} + +class Company : YamlBase { + [string]$Name = "" + [Person]$Ceo = $null + [Person[]]$Employees = @() +} diff --git a/Tests/TaggedScalars.ps1 b/Tests/TaggedScalars.ps1 new file mode 100644 index 0000000..4c70f76 --- /dev/null +++ b/Tests/TaggedScalars.ps1 @@ -0,0 +1,22 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Test class for tagged scalar values +class TaggedScalarsTest : YamlBase { + [string]$StringValue = "" + [int]$IntValue = 0 +} diff --git a/Tests/TypedYamlTestClasses.ps1 b/Tests/TypedYamlTestClasses.ps1 new file mode 100644 index 0000000..1b011c6 --- /dev/null +++ b/Tests/TypedYamlTestClasses.ps1 @@ -0,0 +1,188 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml +using namespace System.Collections.Generic + +class SimpleConfig : YamlBase { + [string]$Name + [int]$Port = 8080 + [bool]$Enabled = $true + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['name'] = $this.Name + $dict['port'] = $this.Port + $dict['enabled'] = $this.Enabled + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('name')) { $this.Name = [string]$data['name'] } + if ($data.ContainsKey('port')) { $this.Port = [int]$data['port'] } + if ($data.ContainsKey('enabled')) { + # Handle both bool and string representations + $val = $data['enabled'] + if ($val -is [bool]) { + $this.Enabled = $val + } else { + $this.Enabled = [System.Convert]::ToBoolean($val) + } + } + } +} + +class DatabaseConfig : YamlBase { + [string]$Host = 'localhost' + [int]$Port = 5432 + [string]$Database + [bool]$UseSsl = $true + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['host'] = $this.Host + $dict['port'] = $this.Port + $dict['database'] = $this.Database + $dict['use-ssl'] = $this.UseSsl + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('host')) { $this.Host = [string]$data['host'] } + if ($data.ContainsKey('port')) { $this.Port = [int]$data['port'] } + if ($data.ContainsKey('database')) { $this.Database = [string]$data['database'] } + if ($data.ContainsKey('use-ssl')) { + # Handle both bool and string representations + $val = $data['use-ssl'] + if ($val -is [bool]) { + $this.UseSsl = $val + } else { + $this.UseSsl = [System.Convert]::ToBoolean($val) + } + } + } +} + +class ComplexConfig : YamlBase { + [string]$AppName + [DatabaseConfig]$Database + [string[]]$Tags = @() + [int]$MaxConnections = 100 + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['app-name'] = $this.AppName + if ($this.Database) { + # Return the YamlBase object itself, not its dictionary + # This preserves metadata during serialization + $dict['database'] = $this.Database + } + $dict['tags'] = $this.Tags + $dict['max-connections'] = $this.MaxConnections + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('app-name')) { $this.AppName = [string]$data['app-name'] } + if ($data.ContainsKey('database')) { + $this.Database = [DatabaseConfig]::new() + $this.Database.FromDictionary($data['database']) + } + if ($data.ContainsKey('tags')) { + $val = $data['tags'] + if ($val -eq $null) { + $this.Tags = @() + } else { + $this.Tags = @($val) + } + } + if ($data.ContainsKey('max-connections')) { $this.MaxConnections = [int]$data['max-connections'] } + } +} + +class SimpleConfigWithArray : YamlBase { + [string]$Name + [int]$Port = 8080 + [string[]]$Tags = @() + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['name'] = $this.Name + $dict['port'] = $this.Port + $dict['tags'] = $this.Tags + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('name')) { $this.Name = [string]$data['name'] } + if ($data.ContainsKey('port')) { $this.Port = [int]$data['port'] } + if ($data.ContainsKey('tags')) { + $val = $data['tags'] + if ($val -eq $null) { + $this.Tags = @() + } else { + $this.Tags = @($val) + } + } + } +} + +class ServerInfo : YamlBase { + [string]$Name + [int]$Port + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['name'] = $this.Name + $dict['port'] = $this.Port + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('name')) { $this.Name = [string]$data['name'] } + if ($data.ContainsKey('port')) { $this.Port = [int]$data['port'] } + } +} + +class ConfigWithServers : YamlBase { + [ServerInfo[]]$Servers = @() + [DatabaseConfig]$Database + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['servers'] = $this.Servers + if ($this.Database) { + $dict['database'] = $this.Database + } + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('servers')) { + $val = $data['servers'] + if ($val -is [System.Collections.IList]) { + $this.Servers = @($val | ForEach-Object { + $server = [ServerInfo]::new() + $server.FromDictionary($_) + $server + }) + } + } + if ($data.ContainsKey('database')) { + $this.Database = [DatabaseConfig]::new() + $this.Database.FromDictionary($data['database']) + } + } +} diff --git a/Tests/case-sensitive-keys.Tests.ps1 b/Tests/case-sensitive-keys.Tests.ps1 new file mode 100644 index 0000000..cd76fca --- /dev/null +++ b/Tests/case-sensitive-keys.Tests.ps1 @@ -0,0 +1,199 @@ +#!/usr/bin/env pwsh +# Case-Sensitive Keys Tests: Demonstrates YamlKey attribute for mapping YAML keys to properties + +BeforeAll { + # Import the main module (now includes typed cmdlets) + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Load test classes + . "$PSScriptRoot/CaseSensitiveKeys.ps1" +} + +Describe "YamlKey Attribute: Case-Sensitive YAML Keys" { + It "Should map case-sensitive YAML keys to different properties" { + # YAML with keys that differ only by case + $yaml = @" +Test: I am uppercase +test: 42 +"@ + + # Deserialize + $obj = $yaml | ConvertFrom-Yaml -As ([CaseSensitiveTest]) + + # Verify values were mapped to correct properties + $obj.CapitalizedTest | Should -Be "I am uppercase" + $obj.LowercaseTest | Should -Be 42 + $obj.CapitalizedTest | Should -BeOfType [string] + $obj.LowercaseTest | Should -BeOfType [int] + } + + It "Should serialize with correct case-sensitive keys" { + # Create object + $obj = [CaseSensitiveTest]::new() + $obj.CapitalizedTest = "Hello" + $obj.LowercaseTest = 100 + + # Serialize + $yaml = $obj | ConvertTo-Yaml + + # Verify keys have correct case + $yaml | Should -Match "Test: Hello" + $yaml | Should -Match "test: 100" + } + + It "Should round-trip case-sensitive keys" { + $yaml = @" +Test: Original uppercase +test: 999 +"@ + + # Deserialize + $obj = $yaml | ConvertFrom-Yaml -As ([CaseSensitiveTest]) + + # Modify values + $obj.CapitalizedTest = "Modified uppercase" + $obj.LowercaseTest = 888 + + # Serialize back + $newYaml = $obj | ConvertTo-Yaml + + # Verify case is preserved + $newYaml | Should -Match "Test: Modified uppercase" + $newYaml | Should -Match "test: 888" + + # Deserialize again to verify + $obj2 = $newYaml | ConvertFrom-Yaml -As ([CaseSensitiveTest]) + $obj2.CapitalizedTest | Should -Be "Modified uppercase" + $obj2.LowercaseTest | Should -Be 888 + } + + It "Should work with mixed attribute and auto-conversion" { + $yaml = @" +custom-key: Custom value +auto-converted-key: 200 +"@ + + $obj = $yaml | ConvertFrom-Yaml -As ([MixedKeysTest]) + + # Attribute-mapped property + $obj.CustomProperty | Should -Be "Custom value" + # Auto-converted property (AutoConvertedKey -> auto-converted-key) + $obj.AutoConvertedKey | Should -Be 200 + } + + It "Should serialize mixed keys correctly" { + $obj = [MixedKeysTest]::new() + $obj.CustomProperty = "Custom" + $obj.AutoConvertedKey = 300 + + $yaml = $obj | ConvertTo-Yaml + + # Verify both key types + $yaml | Should -Match "custom-key: Custom" + $yaml | Should -Match "auto-converted-key: 300" + } + + It "Should preserve metadata for attribute-mapped properties" { + $yaml = @" +# This is a custom key comment +custom-key: Value with comment +auto-converted-key: 150 +"@ + + $obj = $yaml | ConvertFrom-Yaml -As ([MixedKeysTest]) + + # Verify comment is associated with the correct property + $obj.GetPropertyComment('CustomProperty') | Should -Match "custom key comment" + + # Serialize back + $newYaml = $obj | ConvertTo-Yaml + + # Comment should be preserved + $newYaml | Should -Match "# This is a custom key comment" + $newYaml | Should -Match "custom-key: Value with comment" + } +} + +Describe "Duplicate Key Detection" { + It "Should error on duplicate keys in PSCustomObject mode" { + $yaml = @" +test: hello +Test: world +"@ + + # This should throw because PSCustomObject mode doesn't allow case-insensitive duplicates + { $yaml | ConvertFrom-Yaml -As ([PSCustomObject]) } | Should -Throw "*Duplicate key*" + } + + It "Should error on duplicate keys without explicit YamlKey mapping in typed mode" { + $yaml = @" +test: hello +Test: world +"@ + + # This should throw because the type doesn't have YamlKey attributes for both variations + { $yaml | ConvertFrom-Yaml -As ([IWillFailDueToDuplicateKey]) } | Should -Throw "*case-insensitive duplicate keys*" + } + + It "Should succeed with duplicate keys when all are explicitly mapped" { + $yaml = @" +test: hello +Test: world +"@ + + # This should work because both keys are explicitly mapped with YamlKey attributes + $obj = $yaml | ConvertFrom-Yaml -As ([IWillSucceedBecauseIHaveAMappedKey]) + + # Verify both values were mapped correctly + $obj.test | Should -Be "hello" + $obj.alsoTestButUppercase | Should -Be "world" + } + + It "Should round-trip duplicate keys with explicit mappings" { + $yaml = @" +test: lowercase value +Test: uppercase value +"@ + + # Deserialize + $obj = $yaml | ConvertFrom-Yaml -As ([IWillSucceedBecauseIHaveAMappedKey]) + + # Modify values + $obj.test = "modified lowercase" + $obj.alsoTestButUppercase = "modified uppercase" + + # Serialize back + $newYaml = $obj | ConvertTo-Yaml + + # Verify both keys are preserved with correct case + $newYaml | Should -Match "test: modified lowercase" + $newYaml | Should -Match "Test: modified uppercase" + + # Deserialize again to verify + $obj2 = $newYaml | ConvertFrom-Yaml -As ([IWillSucceedBecauseIHaveAMappedKey]) + $obj2.test | Should -Be "modified lowercase" + $obj2.alsoTestButUppercase | Should -Be "modified uppercase" + } + + It "Should error with helpful message indicating which keys are unmapped" { + $yaml = @" +test: value1 +Test: value2 +TEST: value3 +"@ + + # This should throw with a message indicating which keys lack explicit mappings + $errorMessage = "" + try { + $yaml | ConvertFrom-Yaml -As ([IWillFailDueToDuplicateKey]) + } catch { + $errorMessage = $_.Exception.Message + } + + # Error should mention the duplicate keys + $errorMessage | Should -Match "test" + $errorMessage | Should -Match "Test" + # Error should mention the solution + $errorMessage | Should -Match "YamlKey" + } +} diff --git a/Tests/custom-converters.Tests.ps1 b/Tests/custom-converters.Tests.ps1 new file mode 100644 index 0000000..a07c08b --- /dev/null +++ b/Tests/custom-converters.Tests.ps1 @@ -0,0 +1,205 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Tests for custom type converters + +BeforeAll { + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Define test classes + . "$PSScriptRoot/../examples/classes/CustomConverters.ps1" +} + +Describe "Custom Type Converters" { + Context "Local tags (single !)" { + It "Should deserialize !semver tag" { + $yaml = @" +app-name: "LocalTagTest" +version: !semver "1.2.3" +release-date: !datetime "2024-01-15 10:30:00 UTC" +build-date: !datetime "2024-01-15 09:00:00 UTC" +features: ["feature1"] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.Version | Should -Not -BeNullOrEmpty + $config.Version.Major | Should -Be 1 + $config.Version.Minor | Should -Be 2 + $config.Version.Patch | Should -Be 3 + $config.Version.PreRelease | Should -Be "" + } + + It "Should deserialize !datetime tag" { + $yaml = @" +app-name: "LocalTagTest" +version: !semver "1.0.0" +release-date: !datetime "2024-01-15 10:30:00 UTC" +build-date: !datetime "2024-01-15 09:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.ReleaseDate.Year | Should -Be 2024 + $config.ReleaseDate.Month | Should -Be 1 + $config.ReleaseDate.Day | Should -Be 15 + $config.ReleaseDate.Hour | Should -Be 10 + $config.ReleaseDate.Minute | Should -Be 30 + } + } + + Context "Global tags (double !!)" { + It "Should deserialize !!semver tag" { + $yaml = @" +app-name: "GlobalTagTest" +version: !!semver "2.1.5-beta" +release-date: !!datetime "2024-02-20 14:45:30 UTC" +build-date: !!datetime "2024-02-20 12:00:00 UTC" +features: ["feature1", "feature2"] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.Version.Major | Should -Be 2 + $config.Version.Minor | Should -Be 1 + $config.Version.Patch | Should -Be 5 + $config.Version.PreRelease | Should -Be "beta" + } + } + + Context "Full URI tags" { + It "Should deserialize full URI tags" { + $yaml = @" +app-name: "URITagTest" +version: ! "3.0.0" +release-date: ! "2024-03-25 16:20:45 UTC" +build-date: ! "2024-03-25 15:00:00 UTC" +features: ["advanced-feature"] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.Version.Major | Should -Be 3 + $config.Version.Minor | Should -Be 0 + $config.Version.Patch | Should -Be 0 + } + } + + Context "Tag preservation in round-trip" { + It "Should preserve !semver tag in serialization" { + $yaml = @" +app-name: "RoundTripTest" +version: !semver "1.0.0" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2023-12-31 20:00:00 UTC" +features: ["initial"] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + $roundTripped = $config | ConvertTo-Yaml + + $roundTripped | Should -Match "!semver" + } + + It "Should preserve !datetime tag in serialization" { + $yaml = @" +app-name: "RoundTripTest" +version: !semver "1.0.0" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2023-12-31 20:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + $roundTripped = $config | ConvertTo-Yaml + + $roundTripped | Should -Match "!datetime" + } + } + + Context "Multiple input formats" { + It "Should deserialize semver from string format" { + $yaml = @" +app-name: "StringFormat" +version: !semver "2.1.5-beta3" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.Version.ToString() | Should -Be "2.1.5-beta3" + } + + It "Should deserialize semver from dictionary format" { + $yaml = @" +app-name: "DictFormat" +version: !semver { major: 3, minor: 0, patch: 0, pre: "alpha1" } +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.Version.Major | Should -Be 3 + $config.Version.Minor | Should -Be 0 + $config.Version.Patch | Should -Be 0 + $config.Version.PreRelease | Should -Be "alpha1" + } + + It "Should deserialize datetime from dictionary format" { + $yaml = @" +app-name: "DictFormat" +version: !semver "1.0.0" +release-date: !datetime { year: 2025, month: 6, day: 1, hour: 12, minute: 0, second: 0 } +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.ReleaseDate.Year | Should -Be 2025 + $config.ReleaseDate.Month | Should -Be 6 + $config.ReleaseDate.Day | Should -Be 1 + $config.ReleaseDate.Hour | Should -Be 12 + } + } + + Context "Error handling" { + It "Should throw error for invalid semver format" { + $yaml = @" +app-name: "ErrorTest" +version: !semver "not-a-valid-version" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + { $yaml | ConvertFrom-Yaml -As ([AppRelease]) } | Should -Throw + } + } + + Context "Converter registration with string type name" { + It "Should find converter by string type name" { + # This test verifies that [YamlConverter("SemVerConverter")] works + # by resolving the type name to the actual type + $yaml = @" +app-name: "StringTypeNameTest" +version: !semver "1.2.3" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + # If this succeeds, it means the string type name was resolved correctly + $config.Version | Should -Not -BeNullOrEmpty + $config.Version.Major | Should -Be 1 + } + } +} diff --git a/Tests/deep-nested-converters.Tests.ps1 b/Tests/deep-nested-converters.Tests.ps1 new file mode 100644 index 0000000..9af67d9 --- /dev/null +++ b/Tests/deep-nested-converters.Tests.ps1 @@ -0,0 +1,344 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Tests for deeply nested structures with custom type converters + +BeforeAll { + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + . "$PSScriptRoot/DeepNestedConvertersClasses.ps1" +} + +Describe "Deep Nested Custom Converters" { + Context "Three-level nesting with converters at each level" { + It "Should deserialize converters at all nesting levels" { + $yaml = @" +app-name: "ProductionApp" +environment: "prod" +# API Gateway address +api-gateway: !ipaddr "10.0.0.1" +request-timeout: !duration "5h30m0s" +database: + name: "main_db" + # Database host address + host: !ipaddr "192.168.1.100" + port: 5432 + connection-timeout: !duration "0h1m30s" + primary-server: + hostname: "db-primary-01" + address: !ipaddr "192.168.1.101" + port: 5432 + timeout: !duration "0h0m30s" + replica-server: + hostname: "db-replica-01" + address: !ipaddr "192.168.1.102" + port: 5432 + timeout: !duration "0h0m45s" +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + + # Level 1 converters + $config.ApiGateway | Should -Not -BeNullOrEmpty + $config.ApiGateway.ToString() | Should -Be "10.0.0.1" + $config.ApiGateway.Octet1 | Should -Be 10 + $config.ApiGateway.Octet4 | Should -Be 1 + + $config.RequestTimeout | Should -Not -BeNullOrEmpty + $config.RequestTimeout.ToString() | Should -Be "5h30m0s" + $config.RequestTimeout.Hours | Should -Be 5 + $config.RequestTimeout.Minutes | Should -Be 30 + + # Level 2 converters + $config.Database.Host.ToString() | Should -Be "192.168.1.100" + $config.Database.Host.Octet3 | Should -Be 1 + $config.Database.Host.Octet4 | Should -Be 100 + + $config.Database.ConnectionTimeout.ToString() | Should -Be "0h1m30s" + $config.Database.ConnectionTimeout.Minutes | Should -Be 1 + $config.Database.ConnectionTimeout.Seconds | Should -Be 30 + + # Level 3 converters (primary server) + $config.Database.PrimaryServer.Address.ToString() | Should -Be "192.168.1.101" + $config.Database.PrimaryServer.Address.Octet4 | Should -Be 101 + + $config.Database.PrimaryServer.Timeout.ToString() | Should -Be "0h0m30s" + $config.Database.PrimaryServer.Timeout.Seconds | Should -Be 30 + + # Level 3 converters (replica server) + $config.Database.ReplicaServer.Address.ToString() | Should -Be "192.168.1.102" + $config.Database.ReplicaServer.Address.Octet4 | Should -Be 102 + + $config.Database.ReplicaServer.Timeout.ToString() | Should -Be "0h0m45s" + $config.Database.ReplicaServer.Timeout.Seconds | Should -Be 45 + } + + It "Should preserve tags at all nesting levels during round-trip" { + $yaml = @" +app-name: "TestApp" +environment: "test" +api-gateway: !ipaddr "10.1.2.3" +request-timeout: !duration "1h0m0s" +database: + name: "test_db" + host: !ipaddr "127.0.0.1" + port: 3306 + connection-timeout: !duration "0h0m10s" + primary-server: + hostname: "localhost" + address: !ipaddr "127.0.0.1" + port: 3306 + timeout: !duration "0h0m5s" +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + $roundTripped = $config | ConvertTo-Yaml + + # Verify all tags are preserved (note: order may vary) + $roundTripped | Should -Match "!ipaddr" + $roundTripped | Should -Match "!duration" + $roundTripped | Should -Match "10\.1\.2\.3" + $roundTripped | Should -Match "1h0m0s" + $roundTripped | Should -Match "127\.0\.0\.1" + $roundTripped | Should -Match "0h0m10s" + $roundTripped | Should -Match "0h0m5s" + } + + It "Should handle dictionary format for nested converters" { + $yaml = @" +app-name: "DictFormatApp" +environment: "dev" +api-gateway: !ipaddr { a: 172, b: 16, c: 0, d: 1 } +request-timeout: !duration { hours: 2, minutes: 15, seconds: 30 } +database: + name: "dev_db" + host: !ipaddr { a: 192, b: 168, c: 1, d: 50 } + port: 5432 + connection-timeout: !duration { hours: 0, minutes: 2, seconds: 0 } + primary-server: + hostname: "dev-primary" + address: !ipaddr { a: 192, b: 168, c: 1, d: 51 } + port: 5432 + timeout: !duration { hours: 0, minutes: 0, seconds: 20 } +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + + # Verify dictionary format was parsed correctly + $config.ApiGateway.ToString() | Should -Be "172.16.0.1" + $config.RequestTimeout.Hours | Should -Be 2 + $config.RequestTimeout.Minutes | Should -Be 15 + $config.RequestTimeout.Seconds | Should -Be 30 + + $config.Database.Host.ToString() | Should -Be "192.168.1.50" + $config.Database.ConnectionTimeout.Minutes | Should -Be 2 + + $config.Database.PrimaryServer.Address.ToString() | Should -Be "192.168.1.51" + $config.Database.PrimaryServer.Timeout.Seconds | Should -Be 20 + } + + It "Should handle null nested objects with converters" { + $yaml = @" +app-name: "MinimalApp" +environment: "test" +api-gateway: !ipaddr "10.0.0.1" +request-timeout: !duration "1h0m0s" +database: + name: "minimal_db" + host: !ipaddr "127.0.0.1" + port: 5432 + connection-timeout: !duration "0h0m30s" + primary-server: null + replica-server: null +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + + $config.ApiGateway | Should -Not -BeNullOrEmpty + $config.Database | Should -Not -BeNullOrEmpty + $config.Database.Host | Should -Not -BeNullOrEmpty + $config.Database.PrimaryServer | Should -BeNullOrEmpty + $config.Database.ReplicaServer | Should -BeNullOrEmpty + } + + It "Should preserve comments with nested converters" { + $yaml = @" +# Main application config +app-name: "CommentedApp" +environment: "prod" +# Gateway IP +api-gateway: !ipaddr "10.0.0.1" +request-timeout: !duration "2h0m0s" +database: + name: "prod_db" + # Database IP address + host: !ipaddr "192.168.1.100" + port: 5432 + connection-timeout: !duration "0h1m0s" + primary-server: + hostname: "primary" + # Primary server address + address: !ipaddr "192.168.1.101" + port: 5432 + # Server timeout + timeout: !duration "0h0m30s" +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + + # Verify comments are preserved + $config.GetPropertyComment('AppName') | Should -Match "Main application config" + $config.GetPropertyComment('ApiGateway') | Should -Match "Gateway IP" + $config.Database.GetPropertyComment('Host') | Should -Match "Database IP address" + $config.Database.PrimaryServer.GetPropertyComment('Address') | Should -Match "Primary server address" + $config.Database.PrimaryServer.GetPropertyComment('Timeout') | Should -Match "Server timeout" + + # Round-trip and verify comments in output + $roundTripped = $config | ConvertTo-Yaml + + $roundTripped | Should -Match "# Main application config" + $roundTripped | Should -Match "# Gateway IP" + $roundTripped | Should -Match "# Database IP address" + $roundTripped | Should -Match "# Primary server address" + $roundTripped | Should -Match "# Server timeout" + } + + It "Should handle modification of nested converter values" { + $yaml = @" +app-name: "ModifiableApp" +environment: "dev" +api-gateway: !ipaddr "10.0.0.1" +request-timeout: !duration "1h0m0s" +database: + name: "dev_db" + host: !ipaddr "192.168.1.100" + port: 5432 + connection-timeout: !duration "0h0m30s" + primary-server: + hostname: "primary" + address: !ipaddr "192.168.1.101" + port: 5432 + timeout: !duration "0h0m15s" +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + + # Modify nested converter values + $newIp = [CustomIPAddress]@{ + Octet1 = 172 + Octet2 = 16 + Octet3 = 0 + Octet4 = 1 + } + $config.Database.PrimaryServer.Address = $newIp + + $newTimeout = [Duration]@{ + Hours = 0 + Minutes = 1 + Seconds = 0 + } + $config.Database.PrimaryServer.Timeout = $newTimeout + + # Serialize and verify + $roundTripped = $config | ConvertTo-Yaml + + $roundTripped | Should -Match "!ipaddr.*172\.16\.0\.1" + $roundTripped | Should -Match "!duration.*0h1m0s" + } + + It "Should handle errors in nested converter parsing" { + $yaml = @" +app-name: "ErrorApp" +environment: "test" +api-gateway: !ipaddr "not.a.valid.ip" +request-timeout: !duration "1h0m0s" +database: + name: "test_db" + host: !ipaddr "127.0.0.1" + port: 5432 + connection-timeout: !duration "0h0m30s" +"@ + + { $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) } | Should -Throw + } + + It "Should work with -OmitNull on nested converters" { + $config = [ApplicationConfig]::new() + $config.AppName = "NullOmitApp" + $config.Environment = "test" + + $ip = [CustomIPAddress]@{ + Octet1 = 10 + Octet2 = 0 + Octet3 = 0 + Octet4 = 1 + } + $config.ApiGateway = $ip + + $duration = [Duration]@{ + Hours = 1 + Minutes = 0 + Seconds = 0 + } + $config.RequestTimeout = $duration + + # Database is null - should be omitted with -OmitNull + $yaml = $config | ConvertTo-Yaml -OmitNull + + $yaml | Should -Match "api-gateway: !ipaddr 10\.0\.0\.1" + $yaml | Should -Match "request-timeout: !duration 1h0m0s" + $yaml | Should -Not -Match "database:" + } + + It "Should work with -EmitTags on nested converters" { + $config = [ApplicationConfig]::new() + $config.AppName = "TagEmitApp" + $config.Environment = "prod" + + $ip = [CustomIPAddress]@{ + Octet1 = 192 + Octet2 = 168 + Octet3 = 1 + Octet4 = 1 + } + $config.ApiGateway = $ip + + $db = [DatabaseConfig]::new() + $db.Name = "prod_db" + $db.Port = 5432 + + $dbIp = [CustomIPAddress]@{ + Octet1 = 192 + Octet2 = 168 + Octet3 = 1 + Octet4 = 100 + } + $db.Host = $dbIp + + $config.Database = $db + + $yaml = $config | ConvertTo-Yaml -EmitTags + + # Custom converter tags + $yaml | Should -Match "api-gateway: !ipaddr 192\.168\.1\.1" + $yaml | Should -Match "host: !ipaddr 192\.168\.1\.100" + + # Standard tags + $yaml | Should -Match "app-name: !!str TagEmitApp" + $yaml | Should -Match "environment: !!str prod" + $yaml | Should -Match "name: !!str prod_db" + $yaml | Should -Match "port: !!int 5432" + } + } +} diff --git a/Tests/deep-nesting.Tests.ps1 b/Tests/deep-nesting.Tests.ps1 new file mode 100644 index 0000000..9bad68f --- /dev/null +++ b/Tests/deep-nesting.Tests.ps1 @@ -0,0 +1,206 @@ +#!/usr/bin/env pwsh +# Deep Nesting Tests: Demonstrates round-tripping with deeply nested YamlBase objects + +BeforeAll { + # Import the main module (now includes typed cmdlets) + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Load test classes + . "$PSScriptRoot/DeepNestingClasses.ps1" +} + +Describe "Deep Nesting: Round-trip with Complex Object Hierarchies" { + It "Should round-trip deeply nested objects with tags and comments" { + $yaml = @" +# Document describing a family +title: The Smith Family +family: + # Mother's information + mom: + name: Jane Smith + age: !!int 45 + # Father's information + dad: + name: John Smith + age: !!int 47 + # Children in the family + children: + - name: Alice Smith + age: !!int 18 + - name: Bob Smith + age: !!int 15 +"@ + + # Deserialize + $obj = $yaml | ConvertFrom-Yaml -As ([MyClass]) + + # Verify structure + $obj.Title | Should -Be "The Smith Family" + $obj.Family | Should -Not -BeNullOrEmpty + $obj.Family.Mom.Name | Should -Be "Jane Smith" + $obj.Family.Mom.Age | Should -Be 45 + $obj.Family.Dad.Name | Should -Be "John Smith" + $obj.Family.Dad.Age | Should -Be 47 + $obj.Family.Children.Count | Should -Be 2 + $obj.Family.Children[0].Name | Should -Be "Alice Smith" + $obj.Family.Children[0].Age | Should -Be 18 + + # Verify comments were preserved + $obj.GetPropertyComment('Title') | Should -Match "Document describing a family" + $obj.Family.GetPropertyComment('Mom') | Should -Match "Mother's information" + $obj.Family.GetPropertyComment('Dad') | Should -Match "Father's information" + $obj.Family.GetPropertyComment('Children') | Should -Match "Children in the family" + + # Verify tags were preserved + $obj.Family.Mom.GetPropertyTag('Age') | Should -Be "tag:yaml.org,2002:int" + $obj.Family.Dad.GetPropertyTag('Age') | Should -Be "tag:yaml.org,2002:int" + + # Serialize back + $newYaml = $obj | ConvertTo-Yaml + + # Verify tags are in output + $newYaml | Should -Match "age: !!int 45" + $newYaml | Should -Match "age: !!int 47" + $newYaml | Should -Match "age: !!int 18" + $newYaml | Should -Match "age: !!int 15" + + # Verify comments are in output + $newYaml | Should -Match "# Document describing a family" + $newYaml | Should -Match "# Mother's information" + $newYaml | Should -Match "# Father's information" + $newYaml | Should -Match "# Children in the family" + + # Verify structure is preserved + $newYaml | Should -Match "title: The Smith Family" + $newYaml | Should -Match "name: Jane Smith" + $newYaml | Should -Match "name: John Smith" + $newYaml | Should -Match "name: Alice Smith" + $newYaml | Should -Match "name: Bob Smith" + } + + It "Should handle partial null values with -OmitNull" { + $yaml = @" +title: The Johnson Family +family: + mom: + name: Mary Johnson + age: 42 + dad: null + children: + - name: Charlie + age: 10 +"@ + + $obj = $yaml | ConvertFrom-Yaml -As ([MyClass]) + + # Verify structure + $obj.Family.Mom | Should -Not -BeNullOrEmpty + $obj.Family.Dad | Should -BeNullOrEmpty + $obj.Family.Children.Count | Should -Be 1 + + # Serialize without -OmitNull + $yaml1 = $obj | ConvertTo-Yaml + $yaml1 | Should -Match "dad: null" + + # Serialize with -OmitNull + $yaml2 = $obj | ConvertTo-Yaml -OmitNull + $yaml2 | Should -Not -Match "dad:" + $yaml2 | Should -Match "mom:" + $yaml2 | Should -Match "children:" + } + + It "Should emit all tags with -EmitTags on deeply nested objects" { + $obj = [MyClass]::new() + $obj.Title = "The Davis Family" + $obj.Family = [Family]::new() + $obj.Family.Mom = [Person]::new() + $obj.Family.Mom.Name = "Lisa Davis" + $obj.Family.Mom.Age = 38 + $obj.Family.Dad = [Person]::new() + $obj.Family.Dad.Name = "Mike Davis" + $obj.Family.Dad.Age = 40 + $obj.Family.Children = @( + [Person]@{ Name = "Emma Davis"; Age = 12 } + ) + + $yaml = $obj | ConvertTo-Yaml -EmitTags + + # Verify tags on all levels + $yaml | Should -Match "title: !!str The Davis Family" + $yaml | Should -Match "name: !!str Lisa Davis" + $yaml | Should -Match "age: !!int 38" + $yaml | Should -Match "name: !!str Mike Davis" + $yaml | Should -Match "age: !!int 40" + $yaml | Should -Match "name: !!str Emma Davis" + $yaml | Should -Match "age: !!int 12" + } + + It "Should preserve metadata after modification and re-serialization" { + $yaml = @" +title: Original Title +family: + mom: + name: Original Mom + age: !!int 50 +"@ + + $obj = $yaml | ConvertFrom-Yaml -As ([MyClass]) + + # Modify values + $obj.Title = "Modified Title" + $obj.Family.Mom.Age = 51 + + # Add a comment + $obj.Family.Mom.SetPropertyComment('Age', 'Updated age') + + # Serialize + $newYaml = $obj | ConvertTo-Yaml + + # Original tag should still be there + $newYaml | Should -Match "age: !!int 51" + # New comment should be there + $newYaml | Should -Match "# Updated age" + # Modified values should be there + $newYaml | Should -Match "title: Modified Title" + } + + It "Should handle empty arrays vs null arrays correctly" { + $yaml1 = @" +title: Family with no children +family: + mom: + name: Jane + age: 30 + dad: + name: John + age: 32 + children: [] +"@ + + $obj1 = $yaml1 | ConvertFrom-Yaml -As ([MyClass]) + $null -eq $obj1.Family.Children | Should -Be $false + $obj1.Family.Children.Count | Should -Be 0 + + $yaml2 = @" +title: Family with null children +family: + mom: + name: Jane + age: 30 + dad: + name: John + age: 32 + children: null +"@ + + $obj2 = $yaml2 | ConvertFrom-Yaml -As ([MyClass]) + $null -eq $obj2.Family.Children | Should -Be $true + + # Serialize with -OmitNull + $output1 = $obj1 | ConvertTo-Yaml -OmitNull + $output1 | Should -Match "children:" # Empty array is not omitted + + $output2 = $obj2 | ConvertTo-Yaml -OmitNull + $output2 | Should -Not -Match "children:" # Null is omitted + } +} diff --git a/Tests/enhanced-pscustomobject.Tests.ps1 b/Tests/enhanced-pscustomobject.Tests.ps1 new file mode 100644 index 0000000..dab4399 --- /dev/null +++ b/Tests/enhanced-pscustomobject.Tests.ps1 @@ -0,0 +1,932 @@ +BeforeAll { + # Only import if not already loaded to avoid assembly reload issues + if (-not (Get-Module -Name powershell-yaml)) { + Import-Module $PSScriptRoot/../powershell-yaml.psd1 + } +} + +Describe 'Enhanced PSCustomObject Mode Tests' { + + Context 'Basic Enhanced PSCustomObject Creation' { + It 'Should create enhanced PSCustomObject with -As [PSCustomObject]' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj | Should -Not -BeNullOrEmpty + $obj.name | Should -Be 'John' + $obj.age | Should -Be 30 + Test-YamlMetadata $obj | Should -Be $true + } + + It 'Should create nested enhanced PSCustomObjects' { + $yaml = @" +person: + name: John + age: 30 + address: + city: New York + zip: 10001 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.person.name | Should -Be 'John' + $obj.person.address.city | Should -Be 'New York' + Test-YamlMetadata $obj | Should -Be $true + Test-YamlMetadata $obj.person | Should -Be $true + Test-YamlMetadata $obj.person.address | Should -Be $true + } + + It 'Should handle arrays in enhanced PSCustomObject' { + $yaml = @" +items: + - name: item1 + value: 100 + - name: item2 + value: 200 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.items | Should -HaveCount 2 + $obj.items[0].name | Should -Be 'item1' + $obj.items[1].value | Should -Be 200 + } + + It 'Should return regular hashtable when -As is not specified' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml + $obj | Should -BeOfType [hashtable] + Test-YamlMetadata $obj | Should -Be $false + } + } + + Context 'Property Comment Operations' { + It 'Should set and get property comments' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'User full name' + + $comment = Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' + $comment | Should -Be 'User full name' + } + + It 'Should preserve inline comments from YAML' { + $yaml = @" +name: John # User's full name +age: 30 # Age in years +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Comments should be preserved from source YAML + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be "User's full name" + Get-YamlPropertyComment -InputObject $obj -PropertyName 'age' | Should -Be 'Age in years' + } + + It 'Should preserve block comments from YAML' { + $yaml = @" +# This is the user's name +name: John +# This is the user's age +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Block comments should be preserved + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be "This is the user's name" + Get-YamlPropertyComment -InputObject $obj -PropertyName 'age' | Should -Be "This is the user's age" + } + + It 'Should prefer inline comments over block comments' { + $yaml = @" +# Block comment +name: John # Inline comment +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Inline comment should take precedence + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be 'Inline comment' + } + + It 'Should support programmatic comment addition' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Metadata store should exist even if no comments in source + Test-YamlMetadata $obj | Should -Be $true + + # Add comments programmatically + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'User full name' + $obj | Set-YamlPropertyComment -PropertyName 'age' -Comment 'Age in years' + + # Verify comments are stored + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be 'User full name' + Get-YamlPropertyComment -InputObject $obj -PropertyName 'age' | Should -Be 'Age in years' + } + + It 'Should return null for non-existent property comment' { + $yaml = @" +name: John +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $comment = Get-YamlPropertyComment -InputObject $obj -PropertyName 'nonexistent' + $comment | Should -BeNullOrEmpty + } + + It 'Should warn when setting comment on non-enhanced object' { + $obj = [PSCustomObject]@{ name = 'John' } + $warnings = $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'test' 3>&1 + $warnings | Should -Match 'does not have YAML metadata' + } + + It 'Should allow comments on nested properties' { + $yaml = @" +database: + host: localhost + port: 5432 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Set comment on nested property + $obj.database | Set-YamlPropertyComment -PropertyName 'host' -Comment 'Database server' + $obj.database | Set-YamlPropertyComment -PropertyName 'port' -Comment 'Database port' + + # Retrieve nested comments + Get-YamlPropertyComment -InputObject $obj.database -PropertyName 'host' | Should -Be 'Database server' + Get-YamlPropertyComment -InputObject $obj.database -PropertyName 'port' | Should -Be 'Database port' + } + } + + Context 'Scalar Style Operations' { + It 'Should set scalar style for property' { + $yaml = @" +description: Some text +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj | Set-YamlPropertyScalarStyle -PropertyName 'description' -Style Literal + + # Verify it doesn't throw + $obj | Should -Not -BeNullOrEmpty + } + + It 'Should accept valid scalar styles' { + $yaml = "text: value" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + { $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style Plain } | Should -Not -Throw + { $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style SingleQuoted } | Should -Not -Throw + { $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style DoubleQuoted } | Should -Not -Throw + { $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style Literal } | Should -Not -Throw + { $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style Folded } | Should -Not -Throw + } + } + + Context 'Test-YamlMetadata Function' { + It 'Should return true for enhanced PSCustomObject' { + $yaml = "name: John" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + Test-YamlMetadata $obj | Should -Be $true + } + + It 'Should return false for regular PSCustomObject' { + $obj = [PSCustomObject]@{ name = 'John' } + Test-YamlMetadata $obj | Should -Be $false + } + + It 'Should return false for hashtable' { + $yaml = "name: John" + $obj = ConvertFrom-Yaml $yaml + Test-YamlMetadata $obj | Should -Be $false + } + } + + Context 'Complex Nested Structures' { + It 'Should handle deeply nested objects' { + $yaml = @" +level1: + level2: + level3: + value: deep +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.level1.level2.level3.value | Should -Be 'deep' + Test-YamlMetadata $obj.level1.level2.level3 | Should -Be $true + } + + It 'Should handle mixed arrays and objects' { + $yaml = @" +users: + - name: John + roles: + - admin + - user + - name: Jane + roles: + - user +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.users[0].name | Should -Be 'John' + $obj.users[0].roles[0] | Should -Be 'admin' + $obj.users[1].roles | Should -HaveCount 1 + } + } + + Context 'Type Preservation' { + It 'Should preserve boolean types' { + $yaml = @" +enabled: true +disabled: false +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.enabled | Should -BeOfType [bool] + $obj.enabled | Should -Be $true + $obj.disabled | Should -Be $false + } + + It 'Should preserve numeric types' { + $yaml = @" +integer: 42 +long: 9223372036854775807 +decimal: 3.14159 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.integer | Should -BeOfType [int] + $obj.integer | Should -Be 42 + $obj.decimal | Should -Be 3.14159 + } + + It 'Should handle null values' { + $yaml = @" +name: John +middle: null +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.name | Should -Be 'John' + $obj.middle | Should -BeNullOrEmpty + $obj.age | Should -Be 30 + } + + It 'Should preserve BigInteger types' { + $yaml = @" +bignum: 9999999999999999999999999999999999999999999999999 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.bignum | Should -BeOfType [System.Numerics.BigInteger] + $obj.bignum | Should -Be ([System.Numerics.BigInteger]::Parse("9999999999999999999999999999999999999999999999999")) + } + + It 'Should round-trip BigInteger values correctly' { + $yaml = @" +bignum: 9999999999999999999999999999999999999999999999999 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $newYaml = ConvertTo-Yaml $obj + $obj2 = ConvertFrom-Yaml $newYaml -As ([PSCustomObject]) + + $obj2.bignum | Should -BeOfType [System.Numerics.BigInteger] + $obj2.bignum | Should -Be $obj.bignum + } + + It 'Should preserve DateTime types' { + $yaml = @" +timestamp: 2024-01-15T10:30:00Z +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.timestamp | Should -BeOfType [DateTime] + } + + It 'Should round-trip DateTime values correctly' { + $yaml = @" +timestamp: 2024-01-15T10:30:00.0000000Z +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $newYaml = ConvertTo-Yaml $obj + $obj2 = ConvertFrom-Yaml $newYaml -As ([PSCustomObject]) + + $obj2.timestamp | Should -BeOfType [DateTime] + # Compare as strings because DateTime comparison can be tricky with timezones + $obj2.timestamp.ToString("o") | Should -Be $obj.timestamp.ToString("o") + } + + It 'Should handle BigInteger in arrays' { + $yaml = @" +numbers: + - 9999999999999999999999999999999999999999999999999 + - 8888888888888888888888888888888888888888888888888 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.numbers[0] | Should -BeOfType [System.Numerics.BigInteger] + $obj.numbers[1] | Should -BeOfType [System.Numerics.BigInteger] + } + + It 'Should round-trip BigInteger arrays correctly' { + $yaml = @" +numbers: + - 9999999999999999999999999999999999999999999999999 + - 8888888888888888888888888888888888888888888888888 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $newYaml = ConvertTo-Yaml $obj + $obj2 = ConvertFrom-Yaml $newYaml -As ([PSCustomObject]) + + $obj2.numbers[0] | Should -Be $obj.numbers[0] + $obj2.numbers[1] | Should -Be $obj.numbers[1] + } + } + + Context 'Backward Compatibility' { + It 'Should maintain original behavior without -As parameter' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml + $obj | Should -BeOfType [hashtable] + $obj['name'] | Should -Be 'John' + $obj['age'] | Should -Be 30 + } + + It 'Should work with -Ordered parameter when not using -As' { + $yaml = @" +z: last +a: first +"@ + $obj = ConvertFrom-Yaml $yaml -Ordered + $obj | Should -Not -BeNullOrEmpty + # Ordered hashtable functionality is preserved + } + } + + Context 'Metadata Persistence Through Properties' { + It 'Should maintain metadata on nested objects' { + $yaml = @" +database: + host: localhost + port: 5432 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Set comment on nested object + $obj.database | Set-YamlPropertyComment -PropertyName 'host' -Comment 'Database server address' + + $comment = Get-YamlPropertyComment -InputObject $obj.database -PropertyName 'host' + $comment | Should -Be 'Database server address' + } + + It 'Should allow setting metadata on multiple properties' { + $yaml = @" +name: John +age: 30 +email: john@example.com +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'Full name' + $obj | Set-YamlPropertyComment -PropertyName 'age' -Comment 'Age in years' + $obj | Set-YamlPropertyComment -PropertyName 'email' -Comment 'Contact email' + + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be 'Full name' + Get-YamlPropertyComment -InputObject $obj -PropertyName 'age' | Should -Be 'Age in years' + Get-YamlPropertyComment -InputObject $obj -PropertyName 'email' | Should -Be 'Contact email' + } + + It 'Should handle updating existing comments' { + $yaml = "name: John" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'First comment' + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be 'First comment' + + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'Updated comment' + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be 'Updated comment' + } + } + + Context 'Scalar Style Metadata' { + It 'Should set and preserve scalar styles for different properties' { + $yaml = @" +title: Simple Title +description: | + Multi-line + description +code: > + Folded + code +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Set different styles + $obj | Set-YamlPropertyScalarStyle -PropertyName 'title' -Style DoubleQuoted + $obj | Set-YamlPropertyScalarStyle -PropertyName 'description' -Style Literal + $obj | Set-YamlPropertyScalarStyle -PropertyName 'code' -Style Folded + + # Verify no errors occurred + Test-YamlMetadata $obj | Should -Be $true + } + + It 'Should allow changing scalar style after initial setting' { + $yaml = "text: value" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style Plain + $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style DoubleQuoted + $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style SingleQuoted + + Test-YamlMetadata $obj | Should -Be $true + } + } + + Context 'Edge Cases and Error Handling' { + It 'Should handle empty YAML document' { + $yaml = "" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj | Should -BeNullOrEmpty + } + + It 'Should handle YAML with only whitespace' { + $yaml = " `n `n " + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj | Should -BeNullOrEmpty + } + + It 'Should handle arrays with null elements' { + $yaml = @" +items: + - value1 + - null + - value3 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.items | Should -HaveCount 3 + $obj.items[0] | Should -Be 'value1' + $obj.items[1] | Should -BeNullOrEmpty + $obj.items[2] | Should -Be 'value3' + } + + It 'Should handle special YAML values' { + $yaml = @" +null_value: null +tilde_null: ~ +true_value: true +false_value: false +yes_value: yes +no_value: no +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.null_value | Should -BeNullOrEmpty + $obj.tilde_null | Should -BeNullOrEmpty + $obj.true_value | Should -Be $true + $obj.false_value | Should -Be $false + } + + It 'Should handle quoted strings that look like other types' { + $yaml = @" +string_number: "123" +string_bool: "true" +string_null: "null" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.string_number | Should -Be '123' + $obj.string_bool | Should -Be 'true' + $obj.string_null | Should -Be 'null' + } + } + + Context 'Integration with Existing Functions' { + It 'Should work with pipeline operations' { + $yaml = @" +users: + - name: Alice + age: 25 + - name: Bob + age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Pipelineoperations should work + $obj.users | ForEach-Object { $_.name } | Should -Contain 'Alice' + $obj.users | Where-Object { $_.age -gt 25 } | Should -HaveCount 1 + } + + It 'Should allow property access like regular PSCustomObject' { + $yaml = "name: John`nage: 30" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Standard property access + $obj.name | Should -Be 'John' + $obj.PSObject.Properties['age'].Value | Should -Be 30 + + # Property enumeration + $properties = $obj.PSObject.Properties.Name + $properties | Should -Contain 'name' + $properties | Should -Contain 'age' + } + + It 'Should support Get-Member on enhanced objects' { + $yaml = "name: John`nage: 30" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $members = $obj | Get-Member -MemberType NoteProperty + $members | Should -Not -BeNullOrEmpty + $members.Name | Should -Contain 'name' + $members.Name | Should -Contain 'age' + } + } + + Context 'YAML Style Preservation' { + It 'Should preserve flow mapping style' { + $yaml = @" +flow_map: {key1: value1, key2: value2} +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '\{.*\}' + } + + It 'Should preserve flow sequence style' { + $yaml = @" +flow_seq: [item1, item2, item3] +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '\[.*\]' + } + + It 'Should preserve literal string style (|)' { + $yaml = @" +description: | + This is a multi-line + literal string. +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '\|' + } + + It 'Should preserve folded string style (>)' { + $yaml = @" +description: > + This is a folded + multi-line string. +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '>' + } + + It 'Should preserve single-quoted strings' { + $yaml = @" +text: 'hello world' +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match "'" + } + + It 'Should preserve double-quoted strings' { + $yaml = @" +text: "hello world" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '"' + } + + It 'Should preserve mixed flow and block styles' { + $yaml = @" +server: + name: web-01 + ports: [80, 443] + config: {debug: true, timeout: 30} + items: + - one + - two +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '\[.*\]' + $result | Should -Match '\{.*\}' + } + + It 'Should preserve styles after value modification' { + $yaml = @" +ports: [80, 443] +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.ports = @(8080, 8443) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '\[.*\]' + } + } + + Context 'YAML Tag Preservation' { + It 'Should preserve tags when values are unchanged' { + $yaml = @" +number: !!int 42 +text: !!str hello +flag: !!bool true +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!int' + $result | Should -Match '!!str' + $result | Should -Match '!!bool' + } + + It 'Should preserve tags when value changes but type stays same' { + $yaml = @" +number: !!int 42 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.number = 99 + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!int 99' + } + + It 'Should remove tags when type changes' { + $yaml = @" +number: !!int 42 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.number = "not a number" + $result = ConvertTo-Yaml $obj + + $result | Should -Not -Match '!!int' + } + + It 'Should not emit tags when source YAML has no tags' { + $yaml = @" +number: 42 +text: hello +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Not -Match '!!' + } + + It 'Should preserve timestamp tags' { + $yaml = @" +created: !!timestamp 2023-01-15T10:30:00Z +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!timestamp' + } + + It 'Should preserve float tags' { + $yaml = @" +value: !!float 3.14 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!float' + } + + It 'Should preserve tags in nested objects' { + $yaml = @" +server: + port: !!int 8080 + host: !!str localhost +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!int' + $result | Should -Match '!!str' + } + + It 'Should preserve non-specific tag (!) to prevent type inference' { + $yaml = @" +number: ! 42 +flag: ! true +version: ! 1.2.3 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Values should be treated as strings due to "!" tag + $obj.number | Should -BeOfType [string] + $obj.number | Should -Be "42" + $obj.flag | Should -BeOfType [string] + $obj.flag | Should -Be "true" + $obj.version | Should -BeOfType [string] + $obj.version | Should -Be "1.2.3" + + # Round-trip should preserve the "!" tag + $result = ConvertTo-Yaml $obj + $result | Should -Match 'number: ! 42' + $result | Should -Match 'flag: ! true' + $result | Should -Match 'version: ! 1\.2\.3' + + # Parse again to verify it still prevents type inference + $obj2 = ConvertFrom-Yaml $result -As ([PSCustomObject]) + $obj2.number | Should -BeOfType [string] + $obj2.flag | Should -BeOfType [string] + $obj2.version | Should -BeOfType [string] + } + + It 'Should preserve custom tags for round-trip' { + $yaml = @" +id: !uuid 550e8400-e29b-41d4-a716-446655440000 +config: !include config.yaml +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Values should be strings + $obj.id | Should -BeOfType [string] + $obj.config | Should -BeOfType [string] + + # Round-trip should preserve custom tags + $result = ConvertTo-Yaml $obj + $result | Should -Match '!uuid' + $result | Should -Match '!include' + $result | Should -Match '550e8400-e29b-41d4-a716-446655440000' + $result | Should -Match 'config\.yaml' + } + } + + Context 'Quoted Scalar Type Rules' { + It 'Bare scalars should be type-inferred' { + $yaml = @" +number: 42 +flag: true +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.number | Should -BeOfType [int] + $obj.flag | Should -BeOfType [bool] + } + + It 'Double-quoted scalars should be strings (no tag)' { + $yaml = @" +number: "42" +flag: "true" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.number | Should -BeOfType [string] + $obj.number | Should -Be "42" + $obj.flag | Should -BeOfType [string] + $obj.flag | Should -Be "true" + } + + It 'Single-quoted scalars should be strings (no tag)' { + $yaml = @" +number: '42' +flag: 'true' +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.number | Should -BeOfType [string] + $obj.number | Should -Be "42" + $obj.flag | Should -BeOfType [string] + $obj.flag | Should -Be "true" + } + + It 'Tagged quoted scalars should use tag type (tag overrides quotes)' { + $yaml = @" +number: !!int "42" +flag: !!bool "false" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.number | Should -BeOfType [int] + $obj.number | Should -Be 42 + $obj.flag | Should -BeOfType [bool] + $obj.flag | Should -Be $false + } + + It 'Scientific notation should be parsed as decimal' { + $yaml = @" +value: 1.23e-4 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.value | Should -BeOfType [decimal] + } + + It 'Quoted scientific notation should remain string' { + $yaml = @" +value: "1.23e-4" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.value | Should -BeOfType [string] + $obj.value | Should -Be "1.23e-4" + } + + It 'Very large numbers should be BigInteger (not scientific notation)' { + $yaml = @" +bignum: 999999999999999999999999999999999 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.bignum | Should -BeOfType [System.Numerics.BigInteger] + $obj.bignum.ToString() | Should -Not -Match 'e|E' + } + + It 'Literal block scalars should be strings' { + $yaml = @" +text: | + 42 + true +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.text | Should -BeOfType [string] + $obj.text | Should -Match "42" + } + + It 'Folded block scalars should be strings' { + $yaml = @" +text: > + 42 + true +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.text | Should -BeOfType [string] + } + } + + Context 'Tag and Quote Style Combined' { + It 'Should preserve both tag and double quotes' { + $yaml = @" +number: !!int "42" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!int "42"' + } + + It 'Should preserve both tag and single quotes' { + $yaml = @" +number: !!int '42' +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match "!!int '42'" + } + + It 'Should preserve tag and quotes after value change' { + $yaml = @" +number: !!int "42" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.number = 99 + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!int "99"' + } + + It 'Should remove tag but keep quotes when type changes' { + $yaml = @" +number: !!int "42" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.number = "not a number" + $result = ConvertTo-Yaml $obj + + $result | Should -Not -Match '!!int' + $result | Should -Match '"not a number"' + } + + It 'Should handle mixed tag and quote styles' { + $yaml = @" +a: !!int "42" +b: "hello" +c: !!str 99 +d: 'world' +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match 'a: !!int "42"' + $result | Should -Match 'b: "hello"' + $result | Should -Match 'c: !!str' + $result | Should -Match "d: 'world'" + } + } +} diff --git a/Tests/mapping-style.Tests.ps1 b/Tests/mapping-style.Tests.ps1 new file mode 100644 index 0000000..fb55329 --- /dev/null +++ b/Tests/mapping-style.Tests.ps1 @@ -0,0 +1,305 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Tests for mapping style preservation (Flow vs Block) + +BeforeAll { + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + . "$PSScriptRoot/MappingStyleClasses.ps1" +} + +Describe "Mapping Style Preservation" { + Context "Flow style mappings" { + It "Should preserve flow style for nested objects" { + $yaml = @" +name: "TechCorp" +ceo: {name: "swift-narwhal", age: 45, address: {street: "123 Main St", city: "Seattle", zip: "98101"}} +employees: [] +"@ + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify data was parsed correctly + $company.Name | Should -Be "TechCorp" + $company.Ceo.Name | Should -Be "swift-narwhal" + $company.Ceo.Age | Should -Be 45 + $company.Ceo.Address.Street | Should -Be "123 Main St" + $company.Ceo.Address.City | Should -Be "Seattle" + $company.Ceo.Address.Zip | Should -Be "98101" + + # Verify flow style was preserved + $company.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + $company.Ceo.GetPropertyMappingStyle('Address') | Should -Be "Flow" + + # Round-trip and verify flow style is maintained + $roundTripped = $company | ConvertTo-Yaml + + # Flow style should produce inline format + $roundTripped | Should -Match "ceo:\s*\{.*name:.*swift-narwhal.*\}" + $roundTripped | Should -Match "address:.*\{.*street:.*123 Main St.*\}" + } + + It "Should preserve block style for nested objects" { + $yaml = @" +name: "StartupInc" +ceo: + name: "eager-dolphin" + age: 35 + address: + street: "456 Tech Ave" + city: "San Francisco" + zip: "94105" +employees: [] +"@ + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify data was parsed correctly + $company.Ceo.Name | Should -Be "eager-dolphin" + $company.Ceo.Address.City | Should -Be "San Francisco" + + # Verify block style was preserved (or not set, which defaults to block) + $ceoStyle = $company.GetPropertyMappingStyle('Ceo') + $ceoStyle | Should -BeIn @($null, "Block") + + # Round-trip and verify block style is maintained + $roundTripped = $company | ConvertTo-Yaml + + # Block style should produce multi-line format + $roundTripped | Should -Match "ceo:\s*\n\s+name:" + $roundTripped | Should -Match "address:\s*\n\s+street:" + } + + It "Should handle mixed flow and block styles" { + $yaml = @" +name: "MixedCorp" +ceo: + name: "brave-penguin" + age: 50 + address: {street: "789 Business Blvd", city: "Austin", zip: "78701"} +employees: [] +"@ + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # CEO is block style, but address is flow style + $company.GetPropertyMappingStyle('Ceo') | Should -BeIn @($null, "Block") + $company.Ceo.GetPropertyMappingStyle('Address') | Should -Be "Flow" + + # Round-trip + $roundTripped = $company | ConvertTo-Yaml + + # CEO should be block + $roundTripped | Should -Match "ceo:\s*\n\s+name:" + # Address should be flow + $roundTripped | Should -Match "address:.*\{.*street:.*789 Business Blvd.*\}" + } + + It "Should allow programmatic setting of mapping style" { + $company = [Company]::new() + $company.Name = "StyleTest" + + $ceo = [Person]::new() + $ceo.Name = "clever-otter" + $ceo.Age = 40 + + $address = [Address]::new() + $address.Street = "321 Park Ave" + $address.City = "New York" + $address.Zip = "10001" + + $ceo.Address = $address + $company.Ceo = $ceo + + # Set flow style programmatically + $company.SetPropertyMappingStyle('Ceo', 'Flow') + $ceo.SetPropertyMappingStyle('Address', 'Flow') + + # Serialize + $yaml = $company | ConvertTo-Yaml + + # Should use flow style + $yaml | Should -Match "ceo:.*\{.*name:.*clever-otter.*\}" + $yaml | Should -Match "address:.*\{.*street:.*321 Park Ave.*\}" + } + + It "Should default to block style when no metadata" { + $company = [Company]::new() + $company.Name = "DefaultStyleCorp" + + $ceo = [Person]::new() + $ceo.Name = "calm-falcon" + $ceo.Age = 42 + + $company.Ceo = $ceo + + # Don't set any style - should default to block + $yaml = $company | ConvertTo-Yaml + + # Should use block style (multi-line) + $yaml | Should -Match "ceo:\s*\n\s+name:" + } + } + + Context "Flow style with arrays" { + It "Should handle flow style objects in arrays" { + $yaml = @" +name: "ArrayTest" +ceo: {name: "bright-tiger", age: 55, address: null} +employees: + - {name: "happy-panda", age: 30, address: null} + - {name: "wise-raven", age: 28, address: null} +"@ + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify CEO is flow style + $company.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + + # Verify employees array was parsed + $company.Employees.Count | Should -Be 2 + $company.Employees[0].Name | Should -Be "happy-panda" + $company.Employees[1].Name | Should -Be "wise-raven" + + # Round-trip + $roundTripped = $company | ConvertTo-Yaml + + # CEO should remain flow style + $roundTripped | Should -Match "ceo:.*\{.*name:.*bright-tiger.*\}" + } + } + + Context "Entire document in flow style" { + It "Should preserve flow style for entire deeply nested structure" { + $yaml = @" +{name: "FlowCorp", ceo: {name: "gentle-whale", age: 52, address: {street: "999 Flow St", city: "Portland", zip: "97201"}}, employees: [{name: "quick-fox", age: 33, address: {street: "111 Dev Ave", city: "Portland", zip: "97202"}}, {name: "noble-hawk", age: 29, address: {street: "222 Code Ln", city: "Portland", zip: "97203"}}]} +"@ + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify all data was parsed correctly + $company.Name | Should -Be "FlowCorp" + $company.Ceo.Name | Should -Be "gentle-whale" + $company.Ceo.Age | Should -Be 52 + $company.Ceo.Address.Street | Should -Be "999 Flow St" + $company.Ceo.Address.City | Should -Be "Portland" + $company.Ceo.Address.Zip | Should -Be "97201" + + $company.Employees.Count | Should -Be 2 + $company.Employees[0].Name | Should -Be "quick-fox" + $company.Employees[0].Age | Should -Be 33 + $company.Employees[0].Address.Street | Should -Be "111 Dev Ave" + $company.Employees[1].Name | Should -Be "noble-hawk" + $company.Employees[1].Age | Should -Be 29 + $company.Employees[1].Address.City | Should -Be "Portland" + + # Verify document-level flow style + $company.GetDocumentMappingStyle() | Should -Be "Flow" + + # Verify flow style was captured for all nested objects + $company.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + $company.Ceo.GetPropertyMappingStyle('Address') | Should -Be "Flow" + + # Round-trip and verify flow style is maintained throughout + $roundTripped = $company | ConvertTo-Yaml + + # CEO and nested address should be flow style + $roundTripped | Should -Match "ceo:\s*\{.*name:.*gentle-whale.*\}" + $roundTripped | Should -Match "address:\s*\{.*street:.*999 Flow St.*\}" + + # Verify the structure is preserved + $roundTripped | Should -Match "name:.*FlowCorp" + $roundTripped | Should -Match "city:.*Portland" + } + + It "Should round-trip single-line flow style document" { + # Single-line YAML document (all in flow style) + $yaml = "{name: CompactCorp, ceo: {name: silent-spider, age: 47, address: {street: 777 Compact Rd, city: Austin, zip: '78704'}}, employees: []}" + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify parsing + $company.Name | Should -Be "CompactCorp" + $company.Ceo.Name | Should -Be "silent-spider" + $company.Ceo.Address.Street | Should -Be "777 Compact Rd" + $company.Ceo.Address.Zip | Should -Be "78704" + + # Verify root document has flow style (this is what makes it truly single-line) + $company.GetDocumentMappingStyle() | Should -Be "Flow" + + # Verify flow style preserved for nested objects + $company.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + $company.Ceo.GetPropertyMappingStyle('Address') | Should -Be "Flow" + + # Round-trip + $roundTripped = $company | ConvertTo-Yaml + + # The round-tripped output should exactly match the original (minus trailing whitespace) + $roundTripped.Trim() | Should -Be $yaml.Trim() -Because "True round-trip should produce identical output" + + # Verify the output is actually a single line (no newlines except at the end) + $lines = $roundTripped -split "`n" + $nonEmptyLines = $lines | Where-Object { $_ -ne "" } + $nonEmptyLines.Count | Should -Be 1 -Because "Flow style document should be a single line" + + # Verify the round-tripped output matches flow style format + $roundTripped | Should -Match "^\{.*name:.*CompactCorp.*ceo:.*\{.*name:.*silent-spider.*\}.*\}" + + # Parse the round-tripped YAML back to verify exact round-trip + $company2 = $roundTripped | ConvertFrom-Yaml -As ([Company]) + + # Verify data matches exactly + $company2.Name | Should -Be $company.Name + $company2.Ceo.Name | Should -Be $company.Ceo.Name + $company2.Ceo.Age | Should -Be $company.Ceo.Age + $company2.Ceo.Address.Street | Should -Be $company.Ceo.Address.Street + $company2.Ceo.Address.City | Should -Be $company.Ceo.Address.City + $company2.Ceo.Address.Zip | Should -Be $company.Ceo.Address.Zip + $company2.Employees.Count | Should -Be $company.Employees.Count + + # Verify flow style is maintained in round-trip (including root document) + $company2.GetDocumentMappingStyle() | Should -Be "Flow" + $company2.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + $company2.Ceo.GetPropertyMappingStyle('Address') | Should -Be "Flow" + + # Second round-trip should produce identical output (true round-trip stability) + $roundTripped2 = $company2 | ConvertTo-Yaml + $roundTripped2 | Should -Be $roundTripped + } + + It "Should preserve flow style with null values" { + $yaml = "{name: 'NullFlowCorp', ceo: {name: 'proud-eagle', age: 41, address: null}, employees: []}" + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify parsing with null + $company.Name | Should -Be "NullFlowCorp" + $company.Ceo.Name | Should -Be "proud-eagle" + $company.Ceo.Address | Should -BeNullOrEmpty + + # Verify document-level flow style preserved + $company.GetDocumentMappingStyle() | Should -Be "Flow" + + # Verify nested flow style preserved + $company.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + + # Round-trip + $roundTripped = $company | ConvertTo-Yaml + + # CEO should remain flow style even with null address + $roundTripped | Should -Match "ceo:\s*\{.*name:.*proud-eagle.*\}" + } + } +} diff --git a/Tests/metadata.Tests.ps1 b/Tests/metadata.Tests.ps1 new file mode 100644 index 0000000..cf89db5 --- /dev/null +++ b/Tests/metadata.Tests.ps1 @@ -0,0 +1,128 @@ +Import-Module $PSScriptRoot/../powershell-yaml.psd1 -Force + +Describe 'YamlDocumentParser Metadata Preservation Tests' { + BeforeAll { + # The module will load the assemblies automatically + # We just need to ensure YamlDocumentParser type is available + # Get a dummy conversion to ensure assemblies are loaded + $null = ConvertFrom-Yaml "test: value" + } + + Context 'Basic Metadata Parsing' { + It 'Should parse simple YAML document' { + $yaml = @" +name: John +age: 30 +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1 | Should -Not -BeNullOrEmpty + $result.Item1['name'] | Should -Be 'John' + $result.Item1['age'] | Should -Be 30 + $result.Item2 | Should -Not -BeNullOrEmpty + } + + It 'Should parse nested YAML structure' { + $yaml = @" +person: + name: John + age: 30 + address: + city: New York + zip: 10001 +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1 | Should -Not -BeNullOrEmpty + $result.Item1['person']['name'] | Should -Be 'John' + $result.Item1['person']['address']['city'] | Should -Be 'New York' + $result.Item2 | Should -Not -BeNullOrEmpty + } + + It 'Should parse YAML with sequences' { + $yaml = @" +items: + - apple + - banana + - cherry +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1 | Should -Not -BeNullOrEmpty + $result.Item1['items'] | Should -HaveCount 3 + $result.Item1['items'][0] | Should -Be 'apple' + $result.Item1['items'][1] | Should -Be 'banana' + $result.Item1['items'][2] | Should -Be 'cherry' + } + + It 'Should handle null values' { + $yaml = @" +name: John +middle: null +age: 30 +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1 | Should -Not -BeNullOrEmpty + $result.Item1['name'] | Should -Be 'John' + $result.Item1['middle'] | Should -BeNullOrEmpty + $result.Item1['age'] | Should -Be 30 + } + + It 'Should handle boolean values' { + $yaml = @" +enabled: true +disabled: false +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1['enabled'] | Should -Be $true + $result.Item1['disabled'] | Should -Be $false + } + + It 'Should handle numeric values' { + $yaml = @" +integer: 42 +long: 9223372036854775807 +decimal: 3.14159 +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1['integer'] | Should -Be 42 + $result.Item1['integer'] | Should -BeOfType [int] + $result.Item1['long'] | Should -Be 9223372036854775807 + $result.Item1['decimal'] | Should -Be 3.14159 + } + + It 'Should return null for empty document' { + $yaml = "" + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1 | Should -BeNullOrEmpty + $result.Item2 | Should -BeNullOrEmpty + } + } + + Context 'Metadata Store Tests' { + It 'Should create and retrieve property comments' { + $store = New-Object YamlMetadataStore + $store.SetPropertyComment('name', 'User name') + $comment = $store.GetPropertyComment('name') + $comment | Should -Be 'User name' + } + + It 'Should return null for non-existent property comment' { + $store = New-Object YamlMetadataStore + $comment = $store.GetPropertyComment('nonexistent') + $comment | Should -BeNullOrEmpty + } + + It 'Should create and retrieve property tags' { + $store = New-Object YamlMetadataStore + $store.SetPropertyTag('value', 'tag:yaml.org,2002:str') + $tag = $store.GetPropertyTag('value') + $tag | Should -Be 'tag:yaml.org,2002:str' + } + + It 'Should create nested metadata stores' { + $store = New-Object YamlMetadataStore + $nested = $store.GetNestedMetadata('person') + $nested | Should -Not -BeNullOrEmpty + $nested.SetPropertyComment('name', 'Person name') + $nested.GetPropertyComment('name') | Should -Be 'Person name' + } + } +} diff --git a/Tests/powershell-yaml.Tests.ps1 b/Tests/powershell-yaml.Tests.ps1 index d87a284..0a18b4e 100644 --- a/Tests/powershell-yaml.Tests.ps1 +++ b/Tests/powershell-yaml.Tests.ps1 @@ -91,6 +91,150 @@ anArrayKey: [1, 2, 3] Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized } + It "Should serialize BlockStyle correctly (overrides flow style)" { + $obj = [ordered]@{ + aStringKey = "test" + anIntKey = 1 + anArrayKey = @(1, 2, 3) + } + # UseBlockStyle should force block style even when combined with UseFlowStyle + # Block style takes precedence + $expected = @" +aStringKey: test +anIntKey: 1 +anArrayKey: +- 1 +- 2 +- 3 + +"@ + # Test with UseBlockStyle alone (should produce block style - the default) + $serialized = ConvertTo-Yaml -Options UseBlockStyle $obj + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + + # Test UseBlockStyle taking precedence over UseFlowStyle + $serialized = ConvertTo-Yaml -Options ([SerializationOptions]::UseFlowStyle -bor [SerializationOptions]::UseBlockStyle) $obj + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + + $pso = [pscustomobject]$obj + $serialized = ConvertTo-Yaml -Options UseBlockStyle $pso + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + } + + It "Should serialize SequenceBlockStyle correctly (overrides sequence flow style)" { + $obj = [ordered]@{ + aStringKey = "test" + anIntKey = 1 + anArrayKey = @(1, 2, 3) + } + # UseSequenceBlockStyle should force block style for sequences even when combined with UseSequenceFlowStyle + $expected = @" +aStringKey: test +anIntKey: 1 +anArrayKey: +- 1 +- 2 +- 3 + +"@ + # Test with UseSequenceBlockStyle alone + $serialized = ConvertTo-Yaml -Options UseSequenceBlockStyle $obj + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + + # Test UseSequenceBlockStyle taking precedence over UseSequenceFlowStyle + $serialized = ConvertTo-Yaml -Options ([SerializationOptions]::UseSequenceFlowStyle -bor [SerializationOptions]::UseSequenceBlockStyle) $obj + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + + $pso = [pscustomobject]$obj + $serialized = ConvertTo-Yaml -Options UseSequenceBlockStyle $pso + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + } + + It "Should convert block style YAML to flow style (legacy mode)" { + # Parse block style YAML + $blockYaml = @" +name: TestApp +version: 1.2.3 +settings: + debug: true + timeout: 30 + features: + - authentication + - logging + - monitoring + +"@ + $obj = ConvertFrom-Yaml $blockYaml + + # Convert to flow style + $flowYaml = ConvertTo-Yaml -Options UseFlowStyle $obj + + # Should be in flow style (single line with curly braces) + $flowYaml | Should -Match "^\{.*name:.*TestApp.*\}" + $flowYaml | Should -Match "settings:.*\{.*debug:.*true.*\}" + + # Parse back and verify data integrity + $roundTrip = ConvertFrom-Yaml $flowYaml + $roundTrip.name | Should -Be "TestApp" + $roundTrip.version | Should -Be "1.2.3" + $roundTrip.settings.debug | Should -Be $true + $roundTrip.settings.timeout | Should -Be 30 + $roundTrip.settings.features.Count | Should -Be 3 + } + + It "Should convert flow style YAML to block style (legacy mode)" { + # Parse flow style YAML + $flowYaml = "{name: TestApp, version: 1.2.3, settings: {debug: true, timeout: 30, features: [authentication, logging, monitoring]}}" + $obj = ConvertFrom-Yaml $flowYaml + + # Convert to block style (using UseBlockStyle to override any potential flow settings) + $blockYaml = ConvertTo-Yaml -Options UseBlockStyle $obj + + # Should be in block style (multi-line with proper indentation) + $blockYaml | Should -Match "name:.*TestApp" + $blockYaml | Should -Match "settings:\s*\n\s+\w+:" + $blockYaml | Should -Match "features:\s*\n\s*-" + + # Parse back and verify data integrity + $roundTrip = ConvertFrom-Yaml $blockYaml + $roundTrip.name | Should -Be "TestApp" + $roundTrip.version | Should -Be "1.2.3" + $roundTrip.settings.debug | Should -Be $true + $roundTrip.settings.timeout | Should -Be 30 + $roundTrip.settings.features.Count | Should -Be 3 + } + + It "Should convert block sequences to flow sequences (legacy mode)" { + # Parse block style with block sequences + $blockYaml = @" +items: +- apple +- banana +- cherry +metadata: + tags: + - fruit + - fresh + +"@ + $obj = ConvertFrom-Yaml $blockYaml + + # Convert sequences to flow style + $flowYaml = ConvertTo-Yaml -Options UseSequenceFlowStyle $obj + + # Sequences should be in flow style [item1, item2] + $flowYaml | Should -Match "items: \[apple, banana, cherry\]" + $flowYaml | Should -Match "tags: \[fruit, fresh\]" + + # Mappings should still be in block style + $flowYaml | Should -Match "metadata:\s*\n\s+tags:" + + # Parse back and verify + $roundTrip = ConvertFrom-Yaml $flowYaml + $roundTrip.items.Count | Should -Be 3 + $roundTrip.metadata.tags.Count | Should -Be 2 + } + It "Should serialize JsonCompatible correctly" { $obj = [ordered]@{ aStringKey = "test" diff --git a/Tests/roundtrip.Tests.ps1 b/Tests/roundtrip.Tests.ps1 new file mode 100644 index 0000000..965f472 --- /dev/null +++ b/Tests/roundtrip.Tests.ps1 @@ -0,0 +1,43 @@ +Import-Module $PSScriptRoot/../powershell-yaml.psd1 -Force + +Describe 'Round-Trip Comment Preservation Tests' { + It 'Should preserve block comments through round-trip' { + $yaml = @" +# User's full name +name: John +# User's age +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Modify a value + $obj.age = 31 + + # Convert back to YAML + $newYaml = ConvertTo-Yaml $obj + + # Comments should be preserved + $newYaml | Should -Match "# User's full name" + $newYaml | Should -Match "# User's age" + $newYaml | Should -Match "age: 31" + } + + It 'Should preserve programmatically-set comments through round-trip' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Add comments programmatically + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'Full name of user' + $obj | Set-YamlPropertyComment -PropertyName 'age' -Comment 'Age in years' + + # Convert to YAML + $newYaml = ConvertTo-Yaml $obj + + # Comments should be in output + $newYaml | Should -Match "# Full name of user" + $newYaml | Should -Match "# Age in years" + } +} diff --git a/Tests/tagged-scalars.Tests.ps1 b/Tests/tagged-scalars.Tests.ps1 new file mode 100644 index 0000000..7c3f2a7 --- /dev/null +++ b/Tests/tagged-scalars.Tests.ps1 @@ -0,0 +1,102 @@ +#!/usr/bin/env pwsh +# Tagged Scalars Tests: Demonstrates round-tripping YAML with explicit type tags + +BeforeAll { + # Import the main module (now includes typed cmdlets) + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Load test class + . "$PSScriptRoot/TaggedScalars.ps1" +} + +Describe "Tagged Scalars: Round-trip with Explicit Type Tags" { + It "Should round-trip YAML with explicit tags on scalars" { + # Note: PowerShell class properties are case-insensitive, so we use + # string-value and int-value instead of Test/test + # Tags with quoted values should convert the value AND preserve the tag+quoting + $yaml = @" +string-value: !!str "I am a string" +int-value: !!int "22" +"@ + + # Deserialize + $obj = $yaml | ConvertFrom-Yaml -As ([TaggedScalarsTest]) + + # Verify values were parsed correctly - !!int "22" should become int 22 + $obj.StringValue | Should -Be "I am a string" + $obj.IntValue | Should -Be 22 + $obj.StringValue | Should -BeOfType [string] + $obj.IntValue | Should -BeOfType [int] + + # Verify tags were preserved + $obj.GetPropertyTag('StringValue') | Should -Be "tag:yaml.org,2002:str" + $obj.GetPropertyTag('IntValue') | Should -Be "tag:yaml.org,2002:int" + + # Serialize back + $newYaml = $obj | ConvertTo-Yaml + + # Verify tags are in output + $newYaml | Should -Match "string-value: !!str" + $newYaml | Should -Match "int-value: !!int" + + # Verify values are preserved with quoting + # The original had !!int "22" (quoted), so it should serialize back the same way + $newYaml | Should -Match 'string-value: !!str "I am a string"' + $newYaml | Should -Match 'int-value: !!int "22"' + } + + It "Should handle untagged values and infer types" { + # YAML without explicit tags + $yaml = @" +string-value: normal string +int-value: 42 +"@ + + $obj = $yaml | ConvertFrom-Yaml -As ([TaggedScalarsTest]) + + # Values should be converted to correct types + $obj.StringValue | Should -Be "normal string" + $obj.IntValue | Should -Be 42 + $obj.IntValue | Should -BeOfType [int] + + # No tags initially (parsed without tags) + $obj.GetPropertyTag('IntValue') | Should -BeNullOrEmpty + + # Set tags explicitly + $obj.SetPropertyTag('StringValue', 'tag:yaml.org,2002:str') + $obj.SetPropertyTag('IntValue', 'tag:yaml.org,2002:int') + + # Now serialize with tags + $newYaml = $obj | ConvertTo-Yaml + $newYaml | Should -Match "string-value: !!str" + $newYaml | Should -Match "int-value: !!int 42" + } + + It "Should preserve tags when explicitly set" { + $obj = [TaggedScalarsTest]::new() + $obj.StringValue = "Hello World" + $obj.IntValue = 100 + + # Set explicit tags + $obj.SetPropertyTag('StringValue', 'tag:yaml.org,2002:str') + $obj.SetPropertyTag('IntValue', 'tag:yaml.org,2002:int') + + # Serialize + $yaml = $obj | ConvertTo-Yaml + + # Should have both properties with correct tags + $yaml | Should -Match "string-value: !!str" + $yaml | Should -Match "int-value: !!int" + + # Deserialize back + $obj2 = $yaml | ConvertFrom-Yaml -As ([TaggedScalarsTest]) + + # Values should match + $obj2.StringValue | Should -Be "Hello World" + $obj2.IntValue | Should -Be 100 + + # Tags should be preserved + $obj2.GetPropertyTag('StringValue') | Should -Be "tag:yaml.org,2002:str" + $obj2.GetPropertyTag('IntValue') | Should -Be "tag:yaml.org,2002:int" + } +} diff --git a/Tests/typed-yaml-metadata.Tests.ps1 b/Tests/typed-yaml-metadata.Tests.ps1 new file mode 100644 index 0000000..3bda420 --- /dev/null +++ b/Tests/typed-yaml-metadata.Tests.ps1 @@ -0,0 +1,350 @@ +#!/usr/bin/env pwsh +# Typed YAML Metadata Preservation Tests +# Tests that typed YAML mode preserves metadata just like PSCustomObject mode (comments, flow style, quoting style, etc.) + +BeforeAll { + # Import the main module (now includes typed cmdlets) + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Load test classes + . "$PSScriptRoot/TypedYamlTestClasses.ps1" +} + +Describe "Typed YAML: Comment Preservation" { + It "Should preserve comments on simple properties" { + $yaml = @" +# Server name comment +name: test-server +# Port comment +port: 9000 +# Enabled comment +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify comments are captured (use C# property names, not YAML keys) + $config.GetPropertyComment('Name') | Should -Be 'Server name comment' + $config.GetPropertyComment('Port') | Should -Be 'Port comment' + $config.GetPropertyComment('Enabled') | Should -Be 'Enabled comment' + } + + It "Should preserve inline comments" { + $yaml = @" +name: test-server # This is the server name +port: 9000 # This is the port +enabled: true # Server is enabled +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify inline comments are captured (use C# property names) + $config.GetPropertyComment('Name') | Should -Be 'This is the server name' + $config.GetPropertyComment('Port') | Should -Be 'This is the port' + $config.GetPropertyComment('Enabled') | Should -Be 'Server is enabled' + } + + It "Should preserve comments in nested objects" { + $yaml = @" +# App name +app-name: MyApp +database: + # Database host + host: db.example.com + # Database port + port: 5432 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + $config.GetPropertyComment('AppName') | Should -Be 'App name' + $config.Database.GetPropertyComment('Host') | Should -Be 'Database host' + $config.Database.GetPropertyComment('Port') | Should -Be 'Database port' + } + + It "Should preserve comments through round-trip" { + $yaml = @" +# Server name +name: test-server +# Port number +port: 9000 +enabled: true +"@ + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Modify a value + $config1.Port = 8080 + + # Serialize back to YAML + $yaml2 = ConvertTo-Yaml $config1 + + # Verify comments are in output + $yaml2 | Should -Match '# Server name' + $yaml2 | Should -Match '# Port number' + $yaml2 | Should -Match 'port: 8080' + + # Deserialize again + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + + # Verify comments survived round-trip + $config2.GetPropertyComment('Name') | Should -Be 'Server name' + $config2.GetPropertyComment('Port') | Should -Be 'Port number' + $config2.Port | Should -Be 8080 + } +} + +Describe "Typed YAML: Scalar Style Preservation" { + It "Should preserve double-quoted strings" { + $yaml = @" +name: "test-server" +port: 9000 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify scalar style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'DoubleQuoted' + } + + It "Should preserve single-quoted strings" { + $yaml = @" +name: 'test-server' +port: 9000 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify scalar style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'SingleQuoted' + } + + It "Should preserve literal block scalars" { + $yaml = @" +name: |- + test-server + multiline +port: 9000 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify literal style is captured + $config.GetPropertyScalarStyle('Name') | Should -Match 'Literal|Folded' + } + + It "Should preserve scalar styles through round-trip" { + $yaml = @" +name: "quoted-server" +port: 9000 +"@ + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify style was captured + $config1.GetPropertyScalarStyle('Name') | Should -Be 'DoubleQuoted' + + # Round-trip + $yaml2 = ConvertTo-Yaml $config1 + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + + # Style should be preserved or at least the value should be quoted + $yaml2 | Should -Match '"quoted-server"|''quoted-server''' + } +} + +Describe "Typed YAML: Mapping Style Preservation" { + It "Should preserve block mapping style" { + $yaml = @" +app-name: MyApp +database: + host: localhost + port: 5432 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Verify block style is captured (default) + $config.GetPropertyMappingStyle('Database') | Should -Match 'Block|$null' + } + + It "Should preserve flow mapping style" { + $yaml = @" +app-name: MyApp +database: {host: localhost, port: 5432} +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Verify flow style is captured + $config.GetPropertyMappingStyle('Database') | Should -Be 'Flow' + } +} + +Describe "Typed YAML: Sequence Style Preservation" { + It "Should preserve block sequence style" { + $yaml = @" +app-name: ArrayTest +tags: + - tag1 + - tag2 + - tag3 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Verify block sequence style is captured + $config.GetPropertySequenceStyle('Tags') | Should -Match 'Block|$null' + } + + It "Should preserve flow sequence style" { + $yaml = @" +app-name: ArrayTest +tags: [tag1, tag2, tag3] +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Verify flow sequence style is captured + $config.GetPropertySequenceStyle('Tags') | Should -Be 'Flow' + } + + It "Should preserve sequence styles through round-trip" { + $yaml = @" +app-name: ArrayTest +tags: [tag1, tag2, tag3] +"@ + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Round-trip + $yaml2 = ConvertTo-Yaml $config1 + + # Verify flow style is preserved + $yaml2 | Should -Match '\[tag1, tag2, tag3\]' + + # Deserialize again + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([ComplexConfig]) + $config2.GetPropertySequenceStyle('Tags') | Should -Be 'Flow' + } +} + +Describe "Typed YAML: Complete Round-Trip with All Metadata" { + It "Should preserve all metadata types through multiple round-trips" { + $yaml = @" +# Application name +app-name: "MyApp" +# Database configuration +database: + # Database host + host: localhost + port: 5432 + database: mydb + use-ssl: true +# Application tags +tags: [production, web, critical] +# Maximum connections +max-connections: 200 +"@ + # First round-trip + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + $yaml2 = ConvertTo-Yaml $config1 + + # Verify comments are in output + $yaml2 | Should -Match '# Application name' + $yaml2 | Should -Match '# Database configuration' + $yaml2 | Should -Match '# Application tags' + + # Verify flow style preserved + $yaml2 | Should -Match '\[production, web, critical\]' + + # Second round-trip + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([ComplexConfig]) + $yaml3 = ConvertTo-Yaml $config2 + + # Verify metadata survived second round-trip + $yaml3 | Should -Match '# Application name' + $yaml3 | Should -Match '# Database configuration' + $yaml3 | Should -Match '\[production, web, critical\]' + + # Verify values are still correct + $config2.AppName | Should -Be 'MyApp' + $config2.Database.Host | Should -Be 'localhost' + $config2.Tags.Count | Should -Be 3 + $config2.MaxConnections | Should -Be 200 + } + + It "Should allow programmatic metadata modification" { + $yaml = @" +name: test-server +port: 9000 +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Add a comment programmatically + $config.SetPropertyComment('Port', 'Custom port comment') + $config.SetPropertyScalarStyle('Name', 'DoubleQuoted') + + # Serialize + $yaml2 = ConvertTo-Yaml $config + + # Verify programmatic metadata is in output + $yaml2 | Should -Match '# Custom port comment' + $yaml2 | Should -Match '"test-server"' + + # Round-trip and verify + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + $config2.GetPropertyComment('Port') | Should -Be 'Custom port comment' + } +} + +Describe "Typed YAML: Nested Object Metadata" { + It "Should preserve metadata in deeply nested structures" { + $yaml = @" +# Root config +app-name: NestedTest +# Database section +database: + # DB host + host: db.example.com + # DB port + port: 5432 + # DB name + database: mydb + use-ssl: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Verify nested comments + $config.GetPropertyComment('AppName') | Should -Be 'Root config' + $config.GetPropertyComment('Database') | Should -Be 'Database section' + $config.Database.GetPropertyComment('Host') | Should -Be 'DB host' + $config.Database.GetPropertyComment('Port') | Should -Be 'DB port' + $config.Database.GetPropertyComment('Database') | Should -Be 'DB name' + + # Round-trip + $yaml2 = ConvertTo-Yaml $config + + # Verify nested comments in output + $yaml2 | Should -Match '# Root config' + $yaml2 | Should -Match '# Database section' + $yaml2 | Should -Match '# DB host' + $yaml2 | Should -Match '# DB port' + } +} + +Describe "Typed YAML: Metadata Parity with PSCustomObject mode" { + It "Should support same metadata operations as PSCustomObject mode PSCustomObject mode" { + $yaml = @" +# Comment test +name: test +port: 9000 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Test all metadata methods (matching PSCustomObject mode API) + $config.GetPropertyComment('Name') | Should -Not -BeNullOrEmpty + $config.SetPropertyComment('Port', 'New comment') + $config.GetPropertyComment('Port') | Should -Be 'New comment' + + $config.SetPropertyScalarStyle('Name', 'DoubleQuoted') + $config.GetPropertyScalarStyle('Name') | Should -Be 'DoubleQuoted' + + $config.SetPropertyMappingStyle('Name', 'Flow') + $config.GetPropertyMappingStyle('Name') | Should -Be 'Flow' + + $config.SetPropertySequenceStyle('Name', 'Flow') + $config.GetPropertySequenceStyle('Name') | Should -Be 'Flow' + + $config.SetPropertyTag('Name', '!custom') + $config.GetPropertyTag('Name') | Should -Be '!custom' + } +} diff --git a/Tests/typed-yaml.Tests.ps1 b/Tests/typed-yaml.Tests.ps1 new file mode 100644 index 0000000..4a7ba51 --- /dev/null +++ b/Tests/typed-yaml.Tests.ps1 @@ -0,0 +1,724 @@ +#!/usr/bin/env pwsh +# Typed YAML Tests: Typed Class Mode with ALC Isolation + +BeforeAll { + # Import the main module (now includes typed cmdlets) + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Load test classes + . "$PSScriptRoot/TypedYamlTestClasses.ps1" +} + +Describe "Typed YAML: Module Loading and Type Availability" { + It "Should load YamlBase type into Default ALC" { + [PowerShellYaml.YamlBase] | Should -Not -BeNullOrEmpty + } + + It "Should NOT export typed cmdlets (internal use only)" { + Get-Command ConvertFrom-YamlTyped -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + Get-Command ConvertTo-YamlTyped -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + } + + It "Should use unified API (ConvertFrom-Yaml with -As parameter)" { + Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + Get-Command ConvertTo-Yaml -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } + + It "Should allow PowerShell classes to inherit from YamlBase" { + $obj = [SimpleConfig]::new() + $obj -is [PowerShellYaml.YamlBase] | Should -BeTrue + } +} + +Describe "Typed YAML: Simple Deserialization" { + It "Should deserialize simple YAML to typed object" { + $yaml = @" +name: test-server +port: 9000 +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config | Should -Not -BeNullOrEmpty + $config.Name | Should -Be 'test-server' + $config.Port | Should -Be 9000 + $config.Enabled | Should -BeTrue + } + + It "Should preserve type information" { + $yaml = "name: test`nport: 8080`nenabled: false" + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config -is [SimpleConfig] | Should -BeTrue + $config -is [PowerShellYaml.YamlBase] | Should -BeTrue + } + + It "Should use default values for missing properties" { + $yaml = "name: minimal" + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Port | Should -Be 8080 + $config.Enabled | Should -BeTrue + } + + It "Should handle boolean values correctly" { + $yaml = @" +name: bool-test +port: 1000 +enabled: false +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + $config.Enabled | Should -BeFalse + } +} + +Describe "Typed YAML: Simple Serialization" { + It "Should serialize typed object to YAML" { + $config = [SimpleConfig]::new() + $config.Name = 'serialize-test' + $config.Port = 3000 + $config.Enabled = $true + + $yaml = ConvertTo-Yaml $config + + $yaml | Should -Not -BeNullOrEmpty + $yaml | Should -Match 'name: serialize-test' + $yaml | Should -Match 'port: 3000' + $yaml | Should -Match 'enabled: true' + } +} + +Describe "Typed YAML: Round-trip Testing" { + It "Should preserve values through round-trip" { + $yaml = @" +name: roundtrip-test +port: 7777 +enabled: false +"@ + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + $yaml2 = ConvertTo-Yaml $config1 + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + + $config2.Name | Should -Be 'roundtrip-test' + $config2.Port | Should -Be 7777 + $config2.Enabled | Should -BeFalse + } + + It "Should handle modifications in round-trip" { + $yaml = "name: original`nport: 1000" + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Port = 2000 + $config.Enabled = $false + + $yaml2 = ConvertTo-Yaml $config + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + + $config2.Port | Should -Be 2000 + $config2.Enabled | Should -BeFalse + } +} + +Describe "Typed YAML: Nested Object Support" { + It "Should deserialize nested objects" { + $yaml = @" +app-name: MyApp +database: + host: db.example.com + port: 5432 + database: mydb + use-ssl: true +tags: + - production + - web +max-connections: 200 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + $config.AppName | Should -Be 'MyApp' + $config.Database | Should -Not -BeNullOrEmpty + $config.Database.Host | Should -Be 'db.example.com' + $config.Database.Port | Should -Be 5432 + $config.Database.Database | Should -Be 'mydb' + $config.Database.UseSsl | Should -BeTrue + $config.Tags.Count | Should -Be 2 + $config.MaxConnections | Should -Be 200 + } + + It "Should verify nested object types" { + $yaml = @" +app-name: TypeTest +database: + host: localhost + database: test +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + $config.Database -is [DatabaseConfig] | Should -BeTrue + $config.Database -is [PowerShellYaml.YamlBase] | Should -BeTrue + } + + It "Should serialize nested objects" { + $config = [ComplexConfig]::new() + $config.AppName = 'NestedTest' + $config.Database = [DatabaseConfig]::new() + $config.Database.Host = 'nested.example.com' + $config.Database.Port = 3306 + $config.Database.Database = 'nested_db' + + $yaml = ConvertTo-Yaml $config + + $yaml | Should -Match 'app-name: NestedTest' + $yaml | Should -Match 'database:' + $yaml | Should -Match 'host: nested.example.com' + $yaml | Should -Match 'port: 3306' + } + + It "Should round-trip nested objects" { + $yaml1 = @" +app-name: NestedRoundTrip +database: + host: rt.example.com + port: 5433 + database: rt_db + use-ssl: false +"@ + $config1 = ConvertFrom-Yaml -Yaml $yaml1 -As ([ComplexConfig]) + $yaml2 = ConvertTo-Yaml $config1 + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([ComplexConfig]) + + $config2.Database.Host | Should -Be 'rt.example.com' + $config2.Database.Port | Should -Be 5433 + $config2.Database.UseSsl | Should -BeFalse + } +} + +Describe "Typed YAML: Array/Collection Support" { + It "Should deserialize string arrays" { + $yaml = @" +app-name: ArrayTest +tags: + - tag1 + - tag2 + - tag3 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + $config.Tags | Should -Not -BeNullOrEmpty + $config.Tags.Count | Should -Be 3 + $config.Tags[0] | Should -Be 'tag1' + $config.Tags[2] | Should -Be 'tag3' + } + + It "Should serialize string arrays" { + $config = [ComplexConfig]::new() + $config.AppName = 'ArraySerialize' + $config.Tags = @('alpha', 'beta', 'gamma') + + $yaml = ConvertTo-Yaml $config + + $yaml | Should -Match 'tags:' + $yaml | Should -Match '- alpha' + $yaml | Should -Match '- beta' + $yaml | Should -Match '- gamma' + } + + It "Should handle empty arrays" { + $config = [ComplexConfig]::new() + $config.AppName = 'EmptyArray' + $config.Tags = @() + + $yaml = ConvertTo-Yaml $config + $config2 = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Empty arrays may be deserialized as null or empty depending on YAML representation + # Check that it's either null or an empty array + if ($config2.Tags -eq $null) { + # This is acceptable - empty array was serialized as empty/null + $true | Should -BeTrue + } else { + $config2.Tags.Count | Should -Be 0 + } + } +} + +Describe "Typed YAML: Error Handling" { + It "Should throw error for non-YamlBase type" { + $yaml = "name: test" + { ConvertFrom-Yaml -Yaml $yaml -As ([string]) } | Should -Throw + } + + It "Should throw error for null type" { + $yaml = "name: test" + { ConvertFrom-Yaml -Yaml $yaml -As $null } | Should -Throw + } + + It "Should handle invalid YAML gracefully" { + $yaml = "invalid: yaml: structure: bad" + { ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) } | Should -Throw + } +} + +Describe "Typed YAML: Type Conversion" { + It "Should convert string to int" { + $yaml = "name: test`nport: '9999'`nenabled: true" + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Port | Should -Be 9999 + $config.Port | Should -BeOfType [int] + } + + It "Should convert string to bool" { + $yaml = "name: test`nport: 1000`nenabled: 'true'" + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Enabled | Should -BeTrue + $config.Enabled | Should -BeOfType [bool] + } +} + +Describe "Typed YAML: Type Validation" { + # NOTE: Current implementation does NOT validate YAML type tags against class properties. + # Type tags are captured as metadata but deserialization uses property types, not tags. + # This is intentional - we trust the class schema over YAML tags. + + It "Should convert YAML !!int tag to string when property expects string" { + $yaml = @" +name: !!int "42" +port: 8080 +enabled: true +"@ + # Current behavior: Type tag is stored as metadata, but value is converted to property type + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Name | Should -Be "42" + $config.Name | Should -BeOfType [string] + # Type tag should be captured as metadata + $config.GetPropertyTag('Name') | Should -Be 'tag:yaml.org,2002:int' + } + + It "Should convert YAML !!str tag to int when property expects int" { + $yaml = @" +name: test +port: !!str "8080" +enabled: true +"@ + # Current behavior: Type tag is stored as metadata, but value is converted to property type + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Port | Should -Be 8080 + $config.Port | Should -BeOfType [int] + # Type tag should be captured as metadata + $config.GetPropertyTag('Port') | Should -Be 'tag:yaml.org,2002:str' + } + + It "Should convert YAML !!bool tag to string when property expects string" { + $yaml = @" +name: !!bool "false" +port: 8080 +enabled: true +"@ + # Current behavior: Type tag is stored as metadata, but value is converted to property type + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Name | Should -Be "false" + $config.Name | Should -BeOfType [string] + # Type tag should be captured as metadata + $config.GetPropertyTag('Name') | Should -Be 'tag:yaml.org,2002:bool' + } + + It "Should succeed when YAML tag matches property type" { + $yaml = @" +name: !!str "test-server" +port: !!int "8080" +enabled: !!bool "true" +"@ + # This should succeed because tags match property types + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Name | Should -Be "test-server" + $config.Port | Should -Be 8080 + $config.Enabled | Should -BeTrue + } + + It "Should succeed when no explicit tags are provided" { + $yaml = @" +name: test-server +port: 8080 +enabled: true +"@ + # This should succeed - YamlDotNet infers types automatically + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Name | Should -Be "test-server" + $config.Port | Should -Be 8080 + $config.Enabled | Should -BeTrue + } + + It "Should preserve YAML tags through round-trip" { + $yaml = @" +name: !!str "test-server" +port: !!int "8080" +enabled: true +"@ + # Deserialize + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify tags are captured as metadata + $config.GetPropertyTag('Name') | Should -Be 'tag:yaml.org,2002:str' + $config.GetPropertyTag('Port') | Should -Be 'tag:yaml.org,2002:int' + + # Serialize back + $yaml2 = ConvertTo-Yaml $config + + # Tags should be preserved in output (with quotes preserved from original) + $yaml2 | Should -Match 'name: !!str "test-server"' + $yaml2 | Should -Match 'port: !!int "8080"' + + # Round-trip again to verify + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + $config2.GetPropertyTag('Name') | Should -Be 'tag:yaml.org,2002:str' + $config2.GetPropertyTag('Port') | Should -Be 'tag:yaml.org,2002:int' + } + + It "Should preserve plain (unquoted) scalar style" { + $yaml = @" +name: test-server +port: 8080 +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify plain style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'Plain' + + # Serialize back + $yaml2 = ConvertTo-Yaml $config + + # Should remain unquoted + $yaml2 | Should -Match 'name: test-server' + $yaml2 | Should -Not -Match 'name: "test-server"' + $yaml2 | Should -Not -Match "name: 'test-server'" + } + + It "Should preserve double-quoted scalar style" { + $yaml = @" +name: "test-server" +port: 8080 +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify double-quoted style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'DoubleQuoted' + + # Serialize back + $yaml2 = ConvertTo-Yaml $config + + # Should preserve double quotes + $yaml2 | Should -Match 'name: "test-server"' + } + + It "Should preserve single-quoted scalar style" { + $yaml = @" +name: 'test-server' +port: 8080 +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify single-quoted style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'SingleQuoted' + + # Serialize back + $yaml2 = ConvertTo-Yaml $config + + # Should preserve single quotes + $yaml2 | Should -Match "name: 'test-server'" + } + + It "Should preserve mixed quoting styles across properties" { + $yaml = @" +name: plain-value +port: "8080" +enabled: 'true' +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify each style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'Plain' + $config.GetPropertyScalarStyle('Port') | Should -Be 'DoubleQuoted' + $config.GetPropertyScalarStyle('Enabled') | Should -Be 'SingleQuoted' + + # Serialize back + $yaml2 = ConvertTo-Yaml $config + + # Each style should be preserved + $yaml2 | Should -Match 'name: plain-value' + $yaml2 | Should -Match 'port: "8080"' + $yaml2 | Should -Match "enabled: 'true'" + } + + It "Should round-trip quoting styles multiple times" { + $yaml = @" +name: "double-quoted" +port: 8080 +"@ + # First round-trip + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + $yaml2 = ConvertTo-Yaml $config1 + + # Second round-trip + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + $yaml3 = ConvertTo-Yaml $config2 + + # Third round-trip + $config3 = ConvertFrom-Yaml -Yaml $yaml3 -As ([SimpleConfig]) + + # Style should still be double-quoted + $config3.GetPropertyScalarStyle('Name') | Should -Be 'DoubleQuoted' + $yaml4 = ConvertTo-Yaml $config3 + $yaml4 | Should -Match 'name: "double-quoted"' + } +} + +Describe "Typed YAML: ALC Isolation Verification" { + It "Should load YamlDotNet in isolated ALC (not Default)" -Skip:(-not $IsCoreClr) { + # Get all loaded assemblies + $assemblies = [System.AppDomain]::CurrentDomain.GetAssemblies() + $yamlDotNetAsms = @($assemblies | Where-Object { $_.GetName().Name -eq 'YamlDotNet' }) + + # YamlDotNet should be loaded (at least once) + $yamlDotNetAsms.Count | Should -BeGreaterThan 0 + + # Check that YamlDotNet is NOT in the Default ALC + # With LoadFile approach, it's in an anonymous ALC (named after the file path) + $inDefaultAlc = $false + foreach ($asm in $yamlDotNetAsms) { + $alc = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($asm) + if ($alc.Name -eq 'Default') { + $inDefaultAlc = $true + break + } + } + $inDefaultAlc | Should -BeFalse -Because "YamlDotNet should NOT be in Default ALC (should be isolated)" + } + + It "Should load PowerShellYaml in Default ALC" { + $assemblies = [System.AppDomain]::CurrentDomain.GetAssemblies() + $mainAsms = @($assemblies | Where-Object { $_.GetName().Name -eq 'PowerShellYaml' }) + + $mainAsms.Count | Should -BeGreaterThan 0 + + if ($IsCoreClr) { + # Check that at least one PowerShellYaml assembly is in Default ALC + $defaultAlcFound = $false + foreach ($asm in $mainAsms) { + $alc = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($asm) + if ($alc.Name -eq 'Default') { + $defaultAlcFound = $true + break + } + } + $defaultAlcFound | Should -BeTrue -Because "PowerShellYaml should be loaded in Default ALC" + } + } +} + +Describe "Typed YAML: Style Conversion" { + Context "Block to Flow conversion" { + It "Should convert block style YAML to flow style" { + # Parse block style YAML + $blockYaml = @" +app-name: TestApp +database: + host: db.example.com + port: 5432 + database: test_db + use-ssl: true +tags: + - development + - testing +max-connections: 100 +"@ + $config = ConvertFrom-Yaml -Yaml $blockYaml -As ([ComplexConfig]) + + # Convert to flow style + $flowYaml = ConvertTo-Yaml -Data $config -Options UseFlowStyle + + # Should be in flow style (single line with curly braces) + $flowYaml | Should -Match "^\{.*app-name:.*TestApp.*\}" + $flowYaml | Should -Match "database:.*\{.*host:.*db\.example\.com.*\}" + + # Parse back and verify data integrity + $roundTrip = ConvertFrom-Yaml -Yaml $flowYaml -As ([ComplexConfig]) + $roundTrip.AppName | Should -Be "TestApp" + $roundTrip.Database.Host | Should -Be "db.example.com" + $roundTrip.Database.Port | Should -Be 5432 + $roundTrip.Database.Database | Should -Be "test_db" + $roundTrip.Database.UseSsl | Should -Be $true + $roundTrip.Tags.Count | Should -Be 2 + $roundTrip.MaxConnections | Should -Be 100 + } + } + + Context "Flow to Block conversion" { + It "Should convert flow style YAML to block style" { + # Parse flow style YAML + $flowYaml = "{app-name: TestApp, database: {host: db.example.com, port: 5432, database: test_db, use-ssl: true}, tags: [development, testing], max-connections: 100}" + $config = ConvertFrom-Yaml -Yaml $flowYaml -As ([ComplexConfig]) + + # Convert to block style (need both flags for mappings and sequences) + $blockYaml = $config | ConvertTo-Yaml -Options (512 + 1024) # UseBlockStyle + UseSequenceBlockStyle + + # Should be in block style (multi-line with proper indentation) + $blockYaml | Should -Match "app-name:.*TestApp" + $blockYaml | Should -Match "database:\s*\n\s+host:" + $blockYaml | Should -Match "tags:\s*\n\s*-" + + # Parse back and verify data integrity + $roundTrip = ConvertFrom-Yaml -Yaml $blockYaml -As ([ComplexConfig]) + $roundTrip.AppName | Should -Be "TestApp" + $roundTrip.Database.Host | Should -Be "db.example.com" + $roundTrip.Database.Port | Should -Be 5432 + $roundTrip.Database.Database | Should -Be "test_db" + $roundTrip.Database.UseSsl | Should -Be $true + $roundTrip.Tags.Count | Should -Be 2 + $roundTrip.MaxConnections | Should -Be 100 + } + } + + Context "Sequence style conversion" { + It "Should convert block sequences to flow sequences" { + # Create config with arrays + $config = [ComplexConfig]::new() + $config.AppName = "SequenceTest" + $config.Tags = @("tag1", "tag2", "tag3", "tag4") + $config.MaxConnections = 50 + + $db = [DatabaseConfig]::new() + $db.Host = "localhost" + $db.Port = 5432 + $db.Database = "testdb" + $config.Database = $db + + # Default serialization should use block style + $blockYaml = ConvertTo-Yaml -Data $config + $blockYaml | Should -Match "tags:\s*\n\s*-" + + # Convert to flow style for sequences only + $flowSeqYaml = ConvertTo-Yaml -Data $config -Options UseSequenceFlowStyle + $flowSeqYaml | Should -Match "tags: \[tag1, tag2, tag3, tag4\]" + + # Mappings should still be block style + $flowSeqYaml | Should -Match "database:\s*\n\s+host:" + + # Parse back and verify + $roundTrip = ConvertFrom-Yaml -Yaml $flowSeqYaml -As ([ComplexConfig]) + $roundTrip.Tags.Count | Should -Be 4 + $roundTrip.Tags[0] | Should -Be "tag1" + } + + It "Should convert flow sequences to block sequences" { + # Parse YAML with flow sequences + $flowYaml = @" +app-name: SequenceTest +database: + host: localhost + port: 5432 + database: testdb + use-ssl: false +tags: [tag1, tag2, tag3, tag4] +max-connections: 50 +"@ + $config = ConvertFrom-Yaml -Yaml $flowYaml -As ([ComplexConfig]) + + # Convert to block style for sequences + $blockSeqYaml = ConvertTo-Yaml -Data $config -Options UseSequenceBlockStyle + + # Sequences should be in block style + $blockSeqYaml | Should -Match "tags:\s*\n\s*- tag1" + + # Parse back and verify + $roundTrip = ConvertFrom-Yaml -Yaml $blockSeqYaml -As ([ComplexConfig]) + $roundTrip.Tags.Count | Should -Be 4 + $roundTrip.Tags[0] | Should -Be "tag1" + } + } + + Context "Nested object style conversion" { + It "Should handle nested objects with flow style override" { + # Create nested structure + $config = [ComplexConfig]::new() + $config.AppName = "NestedTest" + $config.MaxConnections = 75 + $config.Tags = @("production") + + $db = [DatabaseConfig]::new() + $db.Host = "prod.db.example.com" + $db.Port = 3306 + $db.Database = "prod_db" + $db.UseSsl = $true + $config.Database = $db + + # Serialize with flow style + $flowYaml = ConvertTo-Yaml -Data $config -Options UseFlowStyle + + # Both root and nested objects should be flow style + $flowYaml | Should -Match "^\{" + $flowYaml | Should -Match "database:.*\{.*host:.*prod\.db\.example\.com.*\}" + + # Parse back + $roundTrip = ConvertFrom-Yaml -Yaml $flowYaml -As ([ComplexConfig]) + $roundTrip.Database.Host | Should -Be "prod.db.example.com" + $roundTrip.Database.UseSsl | Should -Be $true + + # Now convert the same object to block style + $blockYaml = ConvertTo-Yaml -Data $roundTrip -Options UseBlockStyle + + # Should be in block style + $blockYaml | Should -Match "database:\s*\n\s+host:" + $blockYaml | Should -Not -Match "^\{" + + # Parse back again + $roundTrip2 = ConvertFrom-Yaml -Yaml $blockYaml -As ([ComplexConfig]) + $roundTrip2.Database.Host | Should -Be "prod.db.example.com" + $roundTrip2.Database.UseSsl | Should -Be $true + } + } +} + +Describe "Typed YAML: Real-world Scenarios" { + It "Should handle complete application configuration" { + $yaml = @" +app-name: ProductionApp +database: + host: prod-db-01.example.com + port: 5432 + database: production + use-ssl: true +tags: + - production + - critical + - monitored +max-connections: 500 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Modify for staging + $config.AppName = 'StagingApp' + $config.Database.Host = 'staging-db-01.example.com' + $config.Database.Database = 'staging' + $config.Tags = @('staging', 'test') + $config.MaxConnections = 100 + + # Serialize and verify + $stagingYaml = ConvertTo-Yaml $config + $stagingConfig = ConvertFrom-Yaml -Yaml $stagingYaml -As ([ComplexConfig]) + + $stagingConfig.AppName | Should -Be 'StagingApp' + $stagingConfig.Database.Host | Should -Be 'staging-db-01.example.com' + $stagingConfig.MaxConnections | Should -Be 100 + $stagingConfig.Tags.Count | Should -Be 2 + } +} diff --git a/build.ps1 b/build.ps1 index 6e14449..1b220f1 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,4 +1,5 @@ -# Copyright 2016-2024 Cloudbase Solutions Srl +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -13,15 +14,187 @@ # under the License. # -$here = Split-Path -Parent $MyInvocation.MyCommand.Path +param( + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', -dotnet build --configuration Release $here/src/ + [switch]$Clean, -$destinations = @("netstandard2.0") + [switch]$SkipTests, -foreach ($item in $destinations) { - $src = Join-Path $here "src" "bin" "Release" $item "PowerShellYamlSerializer.dll" - $dst = Join-Path $here "lib" $item "PowerShellYamlSerializer.dll" + [switch]$IncludeSymbols, - Copy-Item -Force $src $dst + [switch]$Verbose +) + +$ErrorActionPreference = 'Stop' + +if ($Verbose) { + $VerbosePreference = 'Continue' +} + +Write-Host "=======================================" -ForegroundColor Cyan +Write-Host " PowerShell-YAML Build Script " -ForegroundColor Cyan +Write-Host "=======================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Configuration: $Configuration" -ForegroundColor Yellow +Write-Host "" + +$root = $PSScriptRoot +$srcDir = Join-Path $root "src" +$libDir = Join-Path $root "lib" + +# ===== +# Clean +# ===== + +if ($Clean) { + Write-Host "=== Cleaning Build Artifacts ===" -ForegroundColor Cyan + + # Clean .NET build outputs + $cleanPaths = @( + "$srcDir/obj", + "$srcDir/bin", + "$srcDir/*/obj", + "$srcDir/*/bin", + "$srcDir/*/*/obj", + "$srcDir/*/*/bin" + ) + + foreach ($pattern in $cleanPaths) { + Get-Item $pattern -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force + } + + # Clean output directory + if (Test-Path "$libDir/netstandard2.0") { + Remove-Item "$libDir/netstandard2.0" -Recurse -Force + } + + Write-Host "Clean complete" -ForegroundColor Green + Write-Host "" +} + +# ============================== +# Main Module (PowerShell 5.1+) +# ============================== + +Write-Host "=== Building Main Module (netstandard2.0) ===" -ForegroundColor Cyan +Write-Host "" + +# Build PowerShellYaml.Module.dll (netstandard2.0) - contains all serialization code +Write-Host "Building PowerShellYaml.Module.dll..." -ForegroundColor Yellow +Push-Location $srcDir +try { + # Use publish to get all dependencies including YamlDotNet.dll + dotnet publish PowerShellYaml.Module/PowerShellYaml.Module.csproj -c $Configuration -f netstandard2.0 + if ($LASTEXITCODE -ne 0) { + throw "PowerShellYaml.Module build failed with exit code $LASTEXITCODE" + } +} finally { + Pop-Location +} + +# Copy module assemblies to lib/netstandard2.0 +Write-Host "Copying module assemblies to lib/netstandard2.0..." -ForegroundColor Yellow +$netstandard2Dir = Join-Path $libDir "netstandard2.0" +if (!(Test-Path $netstandard2Dir)) { + New-Item -Path $netstandard2Dir -ItemType Directory | Out-Null +} +$moduleSource = Join-Path $srcDir "PowerShellYaml.Module/bin/$Configuration/netstandard2.0/publish" +$assembliesToCopy = @( + 'PowerShellYaml.dll', + 'PowerShellYaml.Module.dll', + 'YamlDotNet.dll' +) + +if ($IncludeSymbols) { + $assembliesToCopy += @( + 'PowerShellYaml.pdb', + 'PowerShellYaml.Module.pdb' + ) } + +foreach ($assemblyName in $assembliesToCopy) { + $sourcePath = Join-Path $moduleSource $assemblyName + if (Test-Path $sourcePath) { + Copy-Item -Path $sourcePath -Destination $netstandard2Dir -Force + Write-Host " ✓ $assemblyName" -ForegroundColor Gray + } else { + Write-Warning " ⚠ $assemblyName not found at $sourcePath" + } +} + +Write-Host "" +Write-Host "Module build complete" -ForegroundColor Green +Write-Host "" + +# ======== +# Summary +# ======== + +Write-Host "=== Build Summary ===" -ForegroundColor Cyan +Write-Host "" + +# Check all module outputs in lib/netstandard2.0 +Write-Host "Module Assemblies (lib/netstandard2.0):" -ForegroundColor Yellow +$allRequired = @( + 'PowerShellYaml.dll', + 'PowerShellYaml.Module.dll', + 'YamlDotNet.dll' +) +$buildSuccess = $true +foreach ($dll in $allRequired) { + $path = Join-Path $netstandard2Dir $dll + if (Test-Path $path) { + $size = (Get-Item $path).Length / 1KB + Write-Host " ✓ $dll ($([math]::Round($size, 1)) KB)" -ForegroundColor Green + } else { + Write-Host " ✗ $dll (missing)" -ForegroundColor Red + $buildSuccess = $false + } +} + +Write-Host "" + +if (!$buildSuccess) { + Write-Host "Build completed with errors" -ForegroundColor Red + exit 1 +} + +Write-Host "✓ All assemblies built successfully" -ForegroundColor Green +Write-Host "" + +# ========== +# Run Tests +# ========== + +if (!$SkipTests) { + Write-Host "=== Running Tests ===" -ForegroundColor Cyan + Write-Host "" + + # Run all tests + $testResult = Invoke-Pester ./Tests/*.Tests.ps1 -Output Normal -PassThru + + Write-Host "" + if ($testResult.FailedCount -eq 0) { + Write-Host "✓ All $($testResult.PassedCount) tests passed" -ForegroundColor Green + } else { + Write-Host "✗ $($testResult.FailedCount) tests failed" -ForegroundColor Red + exit 1 + } + Write-Host "" +} + +# ============== +# Final Summary +# ============== + +Write-Host "=======================================" -ForegroundColor Cyan +Write-Host " Build Complete! " -ForegroundColor Cyan +Write-Host "=======================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Module Location:" -ForegroundColor Yellow +Write-Host " $root" -ForegroundColor Gray +Write-Host "" +Write-Host "To use:" -ForegroundColor Yellow +Write-Host " Import-Module $root/powershell-yaml.psd1" -ForegroundColor Cyan diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..06db0c4 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,494 @@ +# PowerShell-YAML Examples + +This directory contains examples demonstrating the features of PowerShell-YAML with typed class support. + +## Quick Start + +All examples require importing the module: + +```powershell +Import-Module powershell-yaml +``` + +## Examples + +### 1. typed-yaml-demo.ps1 + +**Comprehensive introduction to typed YAML serialization** + +Demonstrates: +- Creating PowerShell classes that inherit from `YamlBase` +- Type-safe deserialization with `-As` parameter +- Nested objects (`AppConfig` → `DatabaseConfig`) +- Array handling (`AllowedOrigins`) +- Round-trip serialization +- Automatic property name conversion (PascalCase → hyphenated-case) + +**Classes**: `classes/DemoClasses.ps1` (`DatabaseConfig`, `AppConfig`) + +**Run**: +```powershell +pwsh -File examples/typed-yaml-demo.ps1 +``` + +### 2. yamlkey-attribute.ps1 + +**YamlKey attribute for case-sensitive YAML keys** + +Demonstrates: +- Using `[YamlKey("key-name")]` attribute +- Mapping case-sensitive YAML keys to different properties +- Solving PowerShell's case-insensitive property limitation + +**Classes**: `classes/ServerConfig.ps1` (`ServerConfig`) + +**Run**: +```powershell +pwsh -File examples/yamlkey-attribute.ps1 +``` + +**Example**: +```powershell +class ServerConfig : YamlBase { + [YamlKey("Host")] # Maps to "Host" in YAML + [string]$PrimaryHost + + [YamlKey("host")] # Maps to "host" in YAML + [string]$BackupHost +} +``` + +### 3. metadata-demo.ps1 + +**YAML metadata preservation (comments, tags, styles)** + +Demonstrates: +- Automatic comment preservation from source YAML +- YAML tag preservation (`!!int`, `!!str`) +- Scalar style preservation (plain, single-quoted, double-quoted) +- Programmatic metadata manipulation via `Get/SetProperty*` methods +- Metadata survival through round-trip serialization + +**Classes**: `classes/DemoClasses.ps1` + +**Run**: +```powershell +pwsh -File examples/metadata-demo.ps1 +``` + +**Example**: +```powershell +# Comments and tags are automatically preserved +$yaml = @" +# Application configuration +app-name: "MyApp" +port: !!int "8080" +"@ + +$config = $yaml | ConvertFrom-YamlTyped -As ([AppConfig]) + +# Access metadata +$config.GetPropertyComment('AppName') # Returns: "Application configuration" +$config.GetPropertyTag('Port') # Returns: "tag:yaml.org,2002:int" + +# Add metadata +$config.SetPropertyComment('Environment', 'Deployment target') + +# Metadata preserved when serializing +$newYaml = $config | ConvertTo-YamlTyped +``` + +### 4. duplicate-key-detection.ps1 + +**Duplicate key detection and prevention** + +Demonstrates: +- PSCustomObject mode rejecting case-insensitive duplicate keys +- Typed mode validation requiring explicit `[YamlKey]` mappings +- Preventing silent data loss from key overwrites +- Handling multiple case variations (`test`, `Test`, `TEST`) +- Round-trip preservation of case-sensitive keys + +**Classes**: `classes/DuplicateKeyClasses.ps1` (`ConfigWithoutMapping`, `ConfigWithMapping`, `ThreeVariations`) + +**Run**: +```powershell +pwsh -File examples/duplicate-key-detection.ps1 +``` + +**Example**: +```powershell +# This YAML has case-insensitive duplicate keys +$yaml = @" +test: hello +Test: world +"@ + +# PSCustomObject mode prevents data loss +$yaml | ConvertFrom-Yaml -As ([PSCustomObject]) # Throws error + +# Typed mode without mappings also fails +class BadConfig : YamlBase { + [string]$test +} +$yaml | ConvertFrom-Yaml -As ([BadConfig]) # Throws error + +# Typed mode with explicit mappings succeeds +class GoodConfig : YamlBase { + [YamlKey("test")] + [string]$LowercaseValue + + [YamlKey("Test")] + [string]$CapitalizedValue +} +$config = $yaml | ConvertFrom-Yaml -As ([GoodConfig]) +# $config.LowercaseValue = "hello" +# $config.CapitalizedValue = "world" +``` + +### 5. advanced-features.ps1 + +**All features combined in a realistic scenario** + +Demonstrates: +- `[YamlKey]` attribute for case-sensitive keys +- Custom YAML key mapping +- Nested `YamlBase` objects +- Arrays of `YamlBase` objects +- Automatic PascalCase → hyphenated-case conversion +- Full metadata preservation +- Complete round-trip with all features working together + +**Classes**: `classes/AdvancedConfig.ps1` (`ServerEndpoint`, `AdvancedConfig`) + +**Run**: +```powershell +pwsh -File examples/advanced-features.ps1 +``` + +### 6. custom-converters.ps1 + +**Custom Type Converters - Extending YAML with Application-Specific Types** + +Demonstrates: +- Creating custom converters by inheriting from `YamlConverter` +- Using `[YamlConverter("ConverterName")]` attribute to register converters +- Handling custom YAML tags (`!semver`, `!datetime`) +- Supporting multiple input formats (string and dictionary) +- Overriding standard YAML type handling +- Full round-trip with custom tag preservation +- Error handling for invalid input + +**Classes**: `classes/CustomConverters.ps1` (`SemanticVersion`, `SemVerConverter`, `CustomDateTimeConverter`, `AppRelease`) + +**Run**: +```powershell +pwsh -File examples/custom-converters.ps1 +``` + +**Example**: +```powershell +# Define a custom type +class SemanticVersion { + [int]$Major = 0 + [int]$Minor = 0 + [int]$Patch = 0 + [string]$PreRelease = "" +} + +# Create a converter +class SemVerConverter : YamlConverter { + [bool] CanHandle([string]$tag, [Type]$targetType) { + return $targetType -eq [SemanticVersion] + } + + [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { + # Parse YAML data into SemanticVersion + # ... + } + + [object] ConvertToYaml([object]$value) { + # Return hashtable with Value and Tag + return @{ + Value = $value.ToString() + Tag = '!semver' + } + } +} + +# Use the converter +class AppRelease : YamlBase { + [YamlConverter("SemVerConverter")] + [SemanticVersion]$Version = $null +} + +# Parse YAML with custom tag +$yaml = "version: !semver '2.1.5-beta3'" +$release = $yaml | ConvertFrom-Yaml -As ([AppRelease]) +# $release.Version.Major = 2 +# $release.Version.Minor = 1 +# $release.Version.Patch = 5 +# $release.Version.PreRelease = "beta3" +``` + +## Class Files + +All class definitions are located in the `classes/` subdirectory and can be dot-sourced as needed. + +### classes/DemoClasses.ps1 + +Simple configuration classes demonstrating: +- No need for manual `ToDictionary`/`FromDictionary` - handled automatically +- Nested `YamlBase` objects +- Arrays + +```powershell +class DatabaseConfig : YamlBase { + [string]$Host = 'localhost' + [int]$Port = 5432 + # ... +} + +class AppConfig : YamlBase { + [string]$AppName = '' + [DatabaseConfig]$Database = $null + [string[]]$AllowedOrigins = @() + # ... +} +``` + +### classes/ServerConfig.ps1 + +Demonstrates `[YamlKey]` attribute: + +```powershell +class ServerConfig : YamlBase { + [YamlKey("Host")] + [string]$PrimaryHost = "" + + [YamlKey("host")] + [string]$BackupHost = "" +} +``` + +### classes/DuplicateKeyClasses.ps1 + +Demonstrates duplicate key detection and prevention: + +```powershell +# Without YamlKey - will fail with duplicate keys +class ConfigWithoutMapping : YamlBase { + [string]$test = "" +} + +# With explicit YamlKey - succeeds with duplicate keys +class ConfigWithMapping : YamlBase { + [YamlKey("test")] + [string]$LowercaseValue = "" + + [YamlKey("Test")] + [string]$CapitalizedValue = "" +} + +# Three case variations +class ThreeVariations : YamlBase { + [YamlKey("test")] + [string]$Lower = "" + + [YamlKey("Test")] + [string]$Capital = "" + + [YamlKey("TEST")] + [string]$Upper = "" +} +``` + +### classes/AdvancedConfig.ps1 + +Complex configuration with all features: + +```powershell +class ServerEndpoint : YamlBase { + [YamlKey("HTTP")] + [string]$HttpUrl = "" + + [YamlKey("HTTPS")] + [string]$HttpsUrl = "" +} + +class AdvancedConfig : YamlBase { + [ServerEndpoint]$PrimaryEndpoint = $null + [ServerEndpoint[]]$BackupEndpoints = @() + + [YamlKey("max-retry-count")] + [int]$MaxRetries = 3 +} +``` + +### classes/CustomConverters.ps1 + +Custom type converters for application-specific types: + +```powershell +# Custom type +class SemanticVersion { + [int]$Major = 0 + [int]$Minor = 0 + [int]$Patch = 0 + [string]$PreRelease = "" + + [string] ToString() { + $ver = "$($this.Major).$($this.Minor).$($this.Patch)" + if ($this.PreRelease) { $ver += "-$($this.PreRelease)" } + return $ver + } +} + +# Converter for SemanticVersion with !semver tag +class SemVerConverter : YamlConverter { + [bool] CanHandle([string]$tag, [Type]$targetType) { + return $targetType -eq [SemanticVersion] + } + + [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { + # Supports both string ("1.2.3-beta") and dictionary formats + # Returns SemanticVersion instance + } + + [object] ConvertToYaml([object]$value) { + return @{ Value = $value.ToString(); Tag = '!semver' } + } +} + +# Usage in a YamlBase class +class AppRelease : YamlBase { + [YamlConverter("SemVerConverter")] + [SemanticVersion]$Version = $null + + [YamlConverter("CustomDateTimeConverter")] + [DateTime]$ReleaseDate = [DateTime]::MinValue +} +``` + +## Key Features + +### 1. Default Implementations + +No need to write `ToDictionary` or `FromDictionary` methods: + +```powershell +# Before (manual implementation required): +class OldClass : YamlBase { + [string]$Name + + [Dictionary[string, object]] ToDictionary() { + # Manual implementation... + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + # Manual implementation... + } +} + +# After (automatic): +class NewClass : YamlBase { + [string]$Name # That's it! +} +``` + +### 2. Automatic Property Name Conversion + +PascalCase → hyphenated-case: + +```powershell +class Config : YamlBase { + [string]$AppName # YAML key: app-name + [string]$DatabaseHost # YAML key: database-host + [int]$MaxConnections # YAML key: max-connections +} +``` + +### 3. YamlKey Attribute + +Override automatic conversion or handle case-sensitive keys: + +```powershell +class Config : YamlBase { + [YamlKey("API-Key")] # Exact YAML key + [string]$ApiKey + + [YamlKey("db_host")] # Use underscores instead of hyphens + [string]$DatabaseHost +} +``` + +### 4. Metadata Preservation + +Comments, tags, and styles automatically preserved: + +```powershell +$yaml = @" +# Important setting +port: !!int "8080" +name: 'MyApp' +"@ + +$config = $yaml | ConvertFrom-YamlTyped -As ([Config]) + +# Metadata is automatically captured +$config.GetPropertyComment('Port') # "Important setting" +$config.GetPropertyTag('Port') # "tag:yaml.org,2002:int" +$config.GetPropertyScalarStyle('Name') # "SingleQuoted" + +# Serialize back - metadata preserved! +$newYaml = $config | ConvertTo-YamlTyped +# Output includes comment, tag, and quotes +``` + +## Tips + +1. **Always initialize properties with default values** to avoid nullability issues: + ```powershell + [string]$Name = "" # Good + [string]$Name # May cause issues + ``` + +2. **Use `YamlKey` for case-sensitive or special format keys**: + ```powershell + [YamlKey("UPPER")] + [string]$UpperCase + + [YamlKey("snake_case")] + [string]$SnakeCase + ``` + +3. **Leverage automatic conversion** for standard keys: + ```powershell + [string]$AppName # Auto-converts to app-name + [int]$MaxConnections # Auto-converts to max-connections + ``` + +4. **Nest YamlBase objects freely** - they're handled automatically: + ```powershell + [DatabaseConfig]$Database = $null + [ServerConfig[]]$Servers = @() + ``` + +5. **Access metadata via `GetProperty*` methods**: + ```powershell + $obj.GetPropertyComment('PropName') + $obj.GetPropertyTag('PropName') + $obj.GetPropertyScalarStyle('PropName') + ``` + +## Running All Examples + +```powershell +# Run each example +pwsh -File examples/typed-yaml-demo.ps1 +pwsh -File examples/yamlkey-attribute.ps1 +pwsh -File examples/metadata-demo.ps1 +pwsh -File examples/duplicate-key-detection.ps1 +pwsh -File examples/advanced-features.ps1 +pwsh -File examples/custom-converters.ps1 +``` diff --git a/examples/advanced-features.ps1 b/examples/advanced-features.ps1 new file mode 100644 index 0000000..6face61 --- /dev/null +++ b/examples/advanced-features.ps1 @@ -0,0 +1,93 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Advanced Features Demo: YamlKey, Nested Objects, Metadata + +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +# Load advanced config class +. "$PSScriptRoot/classes/AdvancedConfig.ps1" + +Write-Host "=== Advanced Features Demo ===" -ForegroundColor Cyan +Write-Host "" + +# YAML with case-sensitive keys, comments, and tags +$yaml = @" +# Service configuration +service-name: ApiGateway +api-version: v2.1 + +# Primary server endpoint +primary-endpoint: + HTTP: http://api.example.com + HTTPS: https://api.example.com + port: !!int 443 + +# Backup endpoints for failover +backup-endpoints: + - HTTP: http://backup1.example.com + HTTPS: https://backup1.example.com + port: 443 + - HTTP: http://backup2.example.com + HTTPS: https://backup2.example.com + port: 443 + +max-retry-count: !!int "5" +"@ + +Write-Host "Step 1: Deserialize YAML with advanced features" -ForegroundColor Green +Write-Host "Source YAML:" -ForegroundColor Yellow +Write-Host $yaml +Write-Host "" + +$config = $yaml | ConvertFrom-Yaml -As ([AdvancedConfig]) + +Write-Host "Step 2: Access deserialized data" -ForegroundColor Green +Write-Host " ServiceName: $($config.ServiceName)" -ForegroundColor Cyan +Write-Host " ApiVersion: $($config.ApiVersion)" -ForegroundColor Cyan +Write-Host " PrimaryEndpoint.HTTP: $($config.PrimaryEndpoint.HttpUrl)" -ForegroundColor Cyan +Write-Host " PrimaryEndpoint.HTTPS: $($config.PrimaryEndpoint.HttpsUrl)" -ForegroundColor Cyan +Write-Host " PrimaryEndpoint.Port: $($config.PrimaryEndpoint.Port)" -ForegroundColor Cyan +Write-Host " BackupEndpoints.Count: $($config.BackupEndpoints.Count)" -ForegroundColor Cyan +Write-Host " MaxRetries: $($config.MaxRetries)" -ForegroundColor Cyan +Write-Host "" + +Write-Host "Step 3: Verify metadata preservation" -ForegroundColor Green +Write-Host " Comment on ServiceName: '$($config.GetPropertyComment('ServiceName'))'" -ForegroundColor Cyan +Write-Host " Comment on PrimaryEndpoint: '$($config.GetPropertyComment('PrimaryEndpoint'))'" -ForegroundColor Cyan +Write-Host " Tag on MaxRetries: '$($config.GetPropertyTag('MaxRetries'))'" -ForegroundColor Cyan +Write-Host " Tag on PrimaryEndpoint.Port: '$($config.PrimaryEndpoint.GetPropertyTag('Port'))'" -ForegroundColor Cyan +Write-Host "" + +Write-Host "Step 4: Modify and serialize back" -ForegroundColor Green +$config.ServiceName = "UpdatedGateway" +$config.MaxRetries = 10 +$config.BackupEndpoints[0].HttpsUrl = "https://new-backup1.example.com" + +$newYaml = $config | ConvertTo-Yaml +Write-Host "Modified YAML:" -ForegroundColor Yellow +Write-Host $newYaml +Write-Host "" + +Write-Host "Step 5: Verify case-sensitive keys preserved" -ForegroundColor Green +if ($newYaml -match 'HTTP:' -and $newYaml -match 'HTTPS:') { + Write-Host " ✓ Case-sensitive keys (HTTP/HTTPS) correctly preserved" -ForegroundColor Green +} +if ($newYaml -match 'max-retry-count:') { + Write-Host " ✓ Custom YAML key (max-retry-count) correctly used" -ForegroundColor Green +} +if ($newYaml -match 'service-name:') { + Write-Host " ✓ Auto-converted key (service-name) correctly used" -ForegroundColor Green +} diff --git a/examples/classes/AdvancedConfig.ps1 b/examples/classes/AdvancedConfig.ps1 new file mode 100644 index 0000000..4bf7095 --- /dev/null +++ b/examples/classes/AdvancedConfig.ps1 @@ -0,0 +1,50 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Advanced configuration class demonstrating: +# - YamlKey attribute for case-sensitive keys +# - Nested YamlBase objects +# - Arrays +# - Automatic property name conversion + +class ServerEndpoint : YamlBase { + # Case-sensitive YAML keys for different protocols + [YamlKey("HTTP")] + [string]$HttpUrl = "" + + [YamlKey("HTTPS")] + [string]$HttpsUrl = "" + + # Normal property with auto-conversion (Port -> port) + [int]$Port = 0 +} + +class AdvancedConfig : YamlBase { + # Standard properties with auto-conversion + [string]$ServiceName = "" + [string]$ApiVersion = "" + + # Nested object + [ServerEndpoint]$PrimaryEndpoint = $null + + # Array of nested objects + [ServerEndpoint[]]$BackupEndpoints = @() + + # Property with custom YAML key + [YamlKey("max-retry-count")] + [int]$MaxRetries = 3 +} diff --git a/examples/classes/CustomConverters.ps1 b/examples/classes/CustomConverters.ps1 new file mode 100644 index 0000000..e288e40 --- /dev/null +++ b/examples/classes/CustomConverters.ps1 @@ -0,0 +1,150 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Example custom type converters using PowerShell classes + +using namespace PowerShellYaml +using namespace System +using namespace System.Collections.Generic + +# Custom type: Semantic Version +class SemanticVersion { + [int]$Major = 0 + [int]$Minor = 0 + [int]$Patch = 0 + [string]$PreRelease = "" + + [string] ToString() { + $ver = "$($this.Major).$($this.Minor).$($this.Patch)" + if ($this.PreRelease) { + $ver += "-$($this.PreRelease)" + } + return $ver + } +} + +# Custom converter for SemanticVersion with !semver tag +class SemVerConverter : YamlConverter { + [bool] CanHandle([string]$tag, [Type]$targetType) { + # Handle !semver tag or when target type is SemanticVersion + return $targetType -eq [SemanticVersion] + } + + [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { + $semver = [SemanticVersion]::new() + + if ($data -is [string]) { + # Parse string format: "1.2.3" or "1.2.3-beta1" + $parts = $data -split '-', 2 + $numbers = $parts[0] -split '\.' + + $semver.Major = [int]$numbers[0] + if ($numbers.Length -gt 1) { $semver.Minor = [int]$numbers[1] } + if ($numbers.Length -gt 2) { $semver.Patch = [int]$numbers[2] } + if ($parts.Length -gt 1) { $semver.PreRelease = $parts[1] } + } + elseif ($data -is [System.Collections.Generic.Dictionary[string, object]]) { + # Parse dictionary format: { major: 1, minor: 2, patch: 3, pre: "beta1" } + if ($data.ContainsKey('major')) { $semver.Major = [int]$data['major'] } + if ($data.ContainsKey('minor')) { $semver.Minor = [int]$data['minor'] } + if ($data.ContainsKey('patch')) { $semver.Patch = [int]$data['patch'] } + if ($data.ContainsKey('pre')) { $semver.PreRelease = [string]$data['pre'] } + } + + return $semver + } + + [object] ConvertToYaml([object]$value) { + $semver = [SemanticVersion]$value + # Return hashtable with Value and Tag + return @{ + Value = $semver.ToString() + Tag = '!semver' + } + } +} + +# Custom DateTime converter that overrides standard timestamp handling +# Uses a specific format instead of ISO8601 +class CustomDateTimeConverter : YamlConverter { + [bool] CanHandle([string]$tag, [Type]$targetType) { + # Handle our custom !datetime tag or when no tag and target is DateTime + return $targetType -eq [DateTime] + } + + [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { + if ($data -is [string]) { + # Parse custom format: "YYYY-MM-DD HH:mm:ss UTC" + if ($data -match '^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) UTC$') { + return [DateTime]::new( + [int]$Matches[1], # Year + [int]$Matches[2], # Month + [int]$Matches[3], # Day + [int]$Matches[4], # Hour + [int]$Matches[5], # Minute + [int]$Matches[6], # Second + [DateTimeKind]::Utc + ) + } + + # Fallback to general parse + return [DateTime]::Parse($data) + } + elseif ($data -is [System.Collections.Generic.Dictionary[string, object]]) { + # Parse dictionary format + $year = [int]$data['year'] + $month = [int]$data['month'] + $day = [int]$data['day'] + $hour = if ($data.ContainsKey('hour')) { [int]$data['hour'] } else { 0 } + $minute = if ($data.ContainsKey('minute')) { [int]$data['minute'] } else { 0 } + $second = if ($data.ContainsKey('second')) { [int]$data['second'] } else { 0 } + + return [DateTime]::new($year, $month, $day, $hour, $minute, $second, [DateTimeKind]::Utc) + } + + throw [FormatException]::new("Invalid datetime format") + } + + [object] ConvertToYaml([object]$value) { + $dt = [DateTime]$value + + # Convert to UTC if not already + if ($dt.Kind -ne [DateTimeKind]::Utc) { + $dt = $dt.ToUniversalTime() + } + + # Return hashtable with Value and Tag + return @{ + Value = $dt.ToString('yyyy-MM-dd HH:mm:ss') + ' UTC' + Tag = '!datetime' + } + } +} + +# Example config class using custom converters +class AppRelease : YamlBase { + [string]$AppName = "" + + [YamlConverter("SemVerConverter")] + [SemanticVersion]$Version = $null + + [YamlConverter("CustomDateTimeConverter")] + [DateTime]$ReleaseDate = [DateTime]::MinValue + + [YamlConverter("CustomDateTimeConverter")] + [DateTime]$BuildDate = [DateTime]::MinValue + + [string[]]$Features = @() +} diff --git a/examples/classes/DemoClasses.ps1 b/examples/classes/DemoClasses.ps1 new file mode 100644 index 0000000..fcbb6b7 --- /dev/null +++ b/examples/classes/DemoClasses.ps1 @@ -0,0 +1,37 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Simple demo classes using the default YamlBase implementations +# No need to implement ToDictionary/FromDictionary - they're handled automatically! +# Property names are automatically converted: PascalCase -> hyphenated-case + +class DatabaseConfig : YamlBase { + [string]$Host = 'localhost' + [int]$Port = 5432 + [string]$Database = '' + [string]$Username = '' + [bool]$UseSsl = $true +} + +class AppConfig : YamlBase { + [string]$AppName = '' + [string]$Version = '' + [string]$Environment = 'development' + [DatabaseConfig]$Database = $null + [int]$MaxConnections = 100 + [string[]]$AllowedOrigins = @() +} diff --git a/examples/classes/DuplicateKeyClasses.ps1 b/examples/classes/DuplicateKeyClasses.ps1 new file mode 100644 index 0000000..71cb107 --- /dev/null +++ b/examples/classes/DuplicateKeyClasses.ps1 @@ -0,0 +1,42 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Example class without YamlKey attributes - will fail with duplicate keys +class ConfigWithoutMapping : YamlBase { + [string]$test = "" +} + +# Example class with explicit YamlKey attributes for duplicate keys +class ConfigWithMapping : YamlBase { + [YamlKey("test")] + [string]$LowercaseValue = "" + + [YamlKey("Test")] + [string]$CapitalizedValue = "" +} + +# Example class demonstrating three case variations +class ThreeVariations : YamlBase { + [YamlKey("test")] + [string]$Lower = "" + + [YamlKey("Test")] + [string]$Capital = "" + + [YamlKey("TEST")] + [string]$Upper = "" +} diff --git a/examples/classes/ServerConfig.ps1 b/examples/classes/ServerConfig.ps1 new file mode 100644 index 0000000..49a1312 --- /dev/null +++ b/examples/classes/ServerConfig.ps1 @@ -0,0 +1,30 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Example class demonstrating YamlKey attribute for case-sensitive YAML keys +class ServerConfig : YamlBase { + # Maps to "Host" in YAML (capitalized) + [YamlKey("Host")] + [string]$PrimaryHost = "" + + # Maps to "host" in YAML (lowercase) + [YamlKey("host")] + [string]$BackupHost = "" + + # Uses automatic conversion: Port -> port + [int]$Port = 0 +} diff --git a/examples/custom-converters.ps1 b/examples/custom-converters.ps1 new file mode 100644 index 0000000..2b1295c --- /dev/null +++ b/examples/custom-converters.ps1 @@ -0,0 +1,145 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Custom Type Converters Demo: Extending YAML with custom types + +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +# Load custom converters +. "$PSScriptRoot/classes/CustomConverters.ps1" + +Write-Host "=== Custom Type Converters Demo ===" -ForegroundColor Cyan +Write-Host "" + +# Example 1: Custom tag for semantic versioning +Write-Host "Example 1: Custom !semver tag" -ForegroundColor Yellow +Write-Host "--------------------------------------" +Write-Host "" + +$yaml1 = @" +app-name: MyAwesomeApp +version: !semver "2.1.5-beta3" +release-date: !datetime "2024-12-15 14:30:00 UTC" +build-date: !datetime "2024-12-15 10:00:00 UTC" +features: + - New user interface + - Performance improvements + - Bug fixes +"@ + +Write-Host "Input YAML with custom tags:" +Write-Host $yaml1 -ForegroundColor Gray +Write-Host "" + +$release = $yaml1 | ConvertFrom-Yaml -As ([AppRelease]) + +Write-Host "Deserialized values:" +Write-Host " App Name: $($release.AppName)" +Write-Host " Version: $($release.Version) (Type: $($release.Version.GetType().Name))" +Write-Host " - Major: $($release.Version.Major)" +Write-Host " - Minor: $($release.Version.Minor)" +Write-Host " - Patch: $($release.Version.Patch)" +Write-Host " - PreRelease: $($release.Version.PreRelease)" +Write-Host " Release Date: $($release.ReleaseDate) (Type: $($release.ReleaseDate.GetType().Name))" +Write-Host " Build Date: $($release.BuildDate)" +Write-Host " Features: $($release.Features.Count) items" +Write-Host "" + +# Modify and serialize back +$release.Version.Patch = 6 +$release.Version.PreRelease = "rc1" +$release.ReleaseDate = [DateTime]::new(2025, 1, 15, 9, 0, 0, [DateTimeKind]::Utc) + +$newYaml = $release | ConvertTo-Yaml + +Write-Host "Modified and serialized back:" +Write-Host $newYaml -ForegroundColor Green +Write-Host "" + +# Example 2: Different input formats with same converter +Write-Host "Example 2: Flexible input formats" -ForegroundColor Yellow +Write-Host "--------------------------------------" +Write-Host "" + +$yaml2 = @" +app-name: FlexibleApp +version: !semver { major: 3, minor: 0, patch: 0, pre: "alpha1" } +release-date: !datetime { year: 2025, month: 6, day: 1, hour: 12, minute: 0, second: 0 } +build-date: !datetime "2025-05-30 18:45:30 UTC" +features: + - Advanced configuration +"@ + +Write-Host "Input YAML with dictionary format for version:" +Write-Host $yaml2 -ForegroundColor Gray +Write-Host "" + +$release2 = $yaml2 | ConvertFrom-Yaml -As ([AppRelease]) + +Write-Host "Deserialized values:" +Write-Host " Version: $($release2.Version)" +Write-Host " Release Date: $($release2.ReleaseDate)" +Write-Host "" + +# Example 3: Round-trip preservation +Write-Host "Example 3: Round-trip with tag preservation" -ForegroundColor Yellow +Write-Host "--------------------------------------" +Write-Host "" + +$yaml3 = @" +app-name: RoundTripTest +version: !semver "1.0.0" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2023-12-31 20:00:00 UTC" +features: + - Initial release +"@ + +$release3 = $yaml3 | ConvertFrom-Yaml -As ([AppRelease]) +$roundTripped = $release3 | ConvertTo-Yaml + +Write-Host "Original YAML:" +Write-Host $yaml3 -ForegroundColor Gray +Write-Host "" + +Write-Host "Round-tripped YAML:" +Write-Host $roundTripped -ForegroundColor Green +Write-Host "" + +# Example 4: Error handling - invalid format +Write-Host "Example 4: Error handling" -ForegroundColor Yellow +Write-Host "--------------------------------------" +Write-Host "" + +$invalidYaml = @" +app-name: ErrorTest +version: !semver "not-a-valid-version" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + +Write-Host "Attempting to parse invalid version format:" +Write-Host $invalidYaml -ForegroundColor Gray +Write-Host "" + +try { + $invalidRelease = $invalidYaml | ConvertFrom-Yaml -As ([AppRelease]) + Write-Host "Parsed successfully (converter handled gracefully)" -ForegroundColor Green + Write-Host " Version: $($invalidRelease.Version)" +} catch { + Write-Host "✓ Error caught as expected:" -ForegroundColor Red + Write-Host " $($_.Exception.Message)" -ForegroundColor Gray +} diff --git a/examples/duplicate-key-detection.ps1 b/examples/duplicate-key-detection.ps1 new file mode 100644 index 0000000..7ea2194 --- /dev/null +++ b/examples/duplicate-key-detection.ps1 @@ -0,0 +1,125 @@ +#!/usr/bin/env pwsh +# Example: Duplicate Key Detection - Preventing Data Loss + +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +# Load class definitions +. "$PSScriptRoot/classes/DuplicateKeyClasses.ps1" + +Write-Host "=== Duplicate Key Detection Demo ===" -ForegroundColor Cyan +Write-Host "" + +# Example 1: PSCustomObject mode rejects duplicate keys +Write-Host "Example 1: PSCustomObject mode prevents data loss" -ForegroundColor Yellow +Write-Host "------------------------------------------------" + +$yaml = @" +test: hello +Test: world +"@ + +Write-Host "Input YAML with case-insensitive duplicate keys:" +Write-Host $yaml +Write-Host "" + +try { + $obj = $yaml | ConvertFrom-Yaml -As ([PSCustomObject]) + Write-Host "ERROR: Should have thrown an error!" -ForegroundColor Red +} catch { + Write-Host "✓ Correctly rejected duplicate keys:" -ForegroundColor Green + Write-Host " $($_.Exception.Message)" -ForegroundColor Gray +} + +Write-Host "" +Write-Host "" + +# Example 2: Typed mode without explicit mappings fails +Write-Host "Example 2: Typed mode without YamlKey attributes" -ForegroundColor Yellow +Write-Host "------------------------------------------------" + +Write-Host "Class definition:" +Write-Host " class ConfigWithoutMapping : YamlBase {" +Write-Host " [string]`$test = `"`"" +Write-Host " }" +Write-Host "" + +try { + $obj = $yaml | ConvertFrom-Yaml -As ([ConfigWithoutMapping]) + Write-Host "ERROR: Should have thrown an error!" -ForegroundColor Red +} catch { + Write-Host "✓ Correctly rejected unmapped duplicate keys:" -ForegroundColor Green + Write-Host " $($_.Exception.Message)" -ForegroundColor Gray +} + +Write-Host "" +Write-Host "" + +# Example 3: Typed mode WITH explicit mappings succeeds +Write-Host "Example 3: Typed mode with explicit YamlKey mappings" -ForegroundColor Yellow +Write-Host "------------------------------------------------" + +Write-Host "Class definition:" +Write-Host " class ConfigWithMapping : YamlBase {" +Write-Host " [YamlKey(`"test`")]" +Write-Host " [string]`$LowercaseValue = `"`"" +Write-Host "" +Write-Host " [YamlKey(`"Test`")]" +Write-Host " [string]`$CapitalizedValue = `"`"" +Write-Host " }" +Write-Host "" + +try { + $obj = $yaml | ConvertFrom-Yaml -As ([ConfigWithMapping]) + Write-Host "✓ Successfully parsed with explicit mappings:" -ForegroundColor Green + Write-Host " LowercaseValue (from 'test'): $($obj.LowercaseValue)" + Write-Host " CapitalizedValue (from 'Test'): $($obj.CapitalizedValue)" +} catch { + Write-Host "ERROR: Should have succeeded!" -ForegroundColor Red + Write-Host " $($_.Exception.Message)" -ForegroundColor Gray +} + +Write-Host "" +Write-Host "" + +# Example 4: Round-trip preservation +Write-Host "Example 4: Round-trip with case-sensitive keys" -ForegroundColor Yellow +Write-Host "------------------------------------------------" + +$obj.LowercaseValue = "modified lowercase" +$obj.CapitalizedValue = "MODIFIED UPPERCASE" + +$newYaml = $obj | ConvertTo-Yaml + +Write-Host "Serialized YAML (case preserved):" +Write-Host $newYaml +Write-Host "" + +# Deserialize again to verify +$obj2 = $newYaml | ConvertFrom-Yaml -As ([ConfigWithMapping]) +Write-Host "✓ Round-trip successful:" -ForegroundColor Green +Write-Host " LowercaseValue: $($obj2.LowercaseValue)" +Write-Host " CapitalizedValue: $($obj2.CapitalizedValue)" + +Write-Host "" +Write-Host "" + +# Example 5: Multiple case variations +Write-Host "Example 5: Three case variations" -ForegroundColor Yellow +Write-Host "------------------------------------------------" + +$yaml3 = @" +test: lowercase +Test: capitalized +TEST: uppercase +"@ + +Write-Host "Input YAML:" +Write-Host $yaml3 +Write-Host "" + +$obj3 = $yaml3 | ConvertFrom-Yaml -As ([ThreeVariations]) + +Write-Host "✓ Successfully parsed three case variations:" -ForegroundColor Green +Write-Host " Lower (from 'test'): $($obj3.Lower)" +Write-Host " Capital (from 'Test'): $($obj3.Capital)" +Write-Host " Upper (from 'TEST'): $($obj3.Upper)" diff --git a/examples/metadata-demo.ps1 b/examples/metadata-demo.ps1 new file mode 100644 index 0000000..f46aaba --- /dev/null +++ b/examples/metadata-demo.ps1 @@ -0,0 +1,82 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Metadata Preservation Demo: Comments, Tags, and Scalar Styles + +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +# Load demo classes +. "$PSScriptRoot/classes/DemoClasses.ps1" + +Write-Host "=== Metadata Preservation Demo ===" -ForegroundColor Cyan +Write-Host "" + +# YAML with rich metadata: comments, tags, and different scalar styles +$yaml = @" +# Application configuration +app-name: "MyApp" +version: '1.0.0' +environment: production + +# Database connection settings +database: + host: db.example.com + port: !!int 5432 + database: myapp_db + username: app_user + use-ssl: true + +max-connections: !!int "100" +allowed-origins: + - https://app.example.com + - https://api.example.com +"@ + +Write-Host "Step 1: Deserialize YAML with metadata" -ForegroundColor Green +Write-Host "Source YAML:" -ForegroundColor Yellow +Write-Host $yaml +Write-Host "" + +$config = $yaml | ConvertFrom-Yaml -As ([AppConfig]) + +Write-Host "Step 2: Inspect preserved metadata" -ForegroundColor Green +Write-Host " Comment on 'AppName': '$($config.GetPropertyComment('AppName'))'" -ForegroundColor Cyan +Write-Host " Comment on 'Database': '$($config.GetPropertyComment('Database'))'" -ForegroundColor Cyan +Write-Host " Tag on 'MaxConnections': '$($config.GetPropertyTag('MaxConnections'))'" -ForegroundColor Cyan +Write-Host " Scalar style on 'AppName': '$($config.GetPropertyScalarStyle('AppName'))'" -ForegroundColor Cyan +Write-Host " Scalar style on 'Version': '$($config.GetPropertyScalarStyle('Version'))'" -ForegroundColor Cyan +Write-Host "" + +Write-Host "Step 3: Modify values while preserving metadata" -ForegroundColor Green +$config.Environment = 'staging' +$config.MaxConnections = 150 +Write-Host " Changed Environment to 'staging'" -ForegroundColor Cyan +Write-Host " Changed MaxConnections to 150" -ForegroundColor Cyan +Write-Host "" + +Write-Host "Step 4: Serialize back with metadata preservation" -ForegroundColor Green +$newYaml = $config | ConvertTo-Yaml +Write-Host "Output YAML:" -ForegroundColor Yellow +Write-Host $newYaml +Write-Host "" + +Write-Host "Step 5: Add new metadata programmatically" -ForegroundColor Green +$config.SetPropertyComment('Environment', 'Deployment environment') +$config.SetPropertyTag('MaxConnections', 'tag:yaml.org,2002:int') +$config.Database.SetPropertyComment('Host', 'Primary database server') + +$newYaml2 = $config | ConvertTo-Yaml +Write-Host "YAML with added metadata:" -ForegroundColor Yellow +Write-Host $newYaml2 diff --git a/examples/typed-yaml-demo.ps1 b/examples/typed-yaml-demo.ps1 new file mode 100644 index 0000000..d994574 --- /dev/null +++ b/examples/typed-yaml-demo.ps1 @@ -0,0 +1,106 @@ +#!/usr/bin/env pwsh +# Typed YAML Demo: Comprehensive Introduction +# This demo showcases PowerShell YAML typed class features +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +Write-Host "=== PowerShell YAML - Typed Class Demo ===" -ForegroundColor Cyan +Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)" -ForegroundColor Yellow +Write-Host "" + +# Import the module +# Write-Host "Step 1: Import PowerShell YAML module" -ForegroundColor Green +# Import-Module ./powershell-yaml.psd1 -Force + +# Verify YamlBase is available +Write-Host " YamlBase type available: $([PowerShellYaml.YamlBase] -ne $null)" -ForegroundColor Cyan +Write-Host "" + +# Load demo classes +Write-Host "Step 2: Load configuration classes" -ForegroundColor Green +. "$PSScriptRoot/classes/DemoClasses.ps1" +Write-Host " Loaded: DatabaseConfig, AppConfig" -ForegroundColor Cyan +Write-Host "" + +# Demonstrate deserialization +Write-Host "Step 3: Deserialize YAML to typed objects" -ForegroundColor Green +$yaml = @" +app-name: MyAwesomeApp +version: 2.0.0 +environment: production +database: + # host is the address for the DB + host: db.example.com + port: !!int 5432 + database: !!str myapp_prod + username: app_user + use-ssl: true +max-connections: 200 +allowed-origins: + - https://app.example.com + - https://admin.example.com +"@ + +Write-Host " Source YAML:" -ForegroundColor Yellow +Write-Host $yaml +Write-Host "" + +$config = ConvertFrom-Yaml -Yaml $yaml -As ([AppConfig]) + +Write-Host " Deserialized object:" -ForegroundColor Yellow +Write-Host " AppName: $($config.AppName)" -ForegroundColor Cyan +Write-Host " Version: $($config.Version)" -ForegroundColor Cyan +Write-Host " Environment: $($config.Environment)" -ForegroundColor Cyan +Write-Host " Database.Host: $($config.Database.Host)" -ForegroundColor Cyan +Write-Host " Database.Port: $($config.Database.Port)" -ForegroundColor Cyan +Write-Host " Database.UseSsl: $($config.Database.UseSsl)" -ForegroundColor Cyan +Write-Host " MaxConnections: $($config.MaxConnections)" -ForegroundColor Cyan +Write-Host " AllowedOrigins: $($config.AllowedOrigins -join ', ')" -ForegroundColor Cyan +Write-Host "" + +# Verify type safety +Write-Host "Step 4: Verify type safety" -ForegroundColor Green +Write-Host " config is [AppConfig]: $($config -is [AppConfig])" -ForegroundColor Cyan +Write-Host " config is [YamlBase]: $($config -is [PowerShellYaml.YamlBase])" -ForegroundColor Cyan +Write-Host " config.Database is [DatabaseConfig]: $($config.Database -is [DatabaseConfig])" -ForegroundColor Cyan +Write-Host "" + +# Modify and serialize +Write-Host "Step 5: Modify configuration and serialize back to YAML" -ForegroundColor Green +$config.Environment = 'staging' +$config.MaxConnections = 150 +$config.AllowedOrigins += 'https://staging.example.com' +$config.Database.Port = 5433 + +$outputYaml = ConvertTo-Yaml $config + +Write-Host " Modified YAML:" -ForegroundColor Yellow +Write-Host $outputYaml +Write-Host "" + +# Round-trip test +Write-Host "Step 6: Round-trip verification" -ForegroundColor Green +$config2 = ConvertFrom-Yaml -Yaml $outputYaml -As ([AppConfig]) + +Write-Host " Environment preserved: $($config2.Environment -eq 'staging')" -ForegroundColor Cyan +Write-Host " MaxConnections preserved: $($config2.MaxConnections -eq 150)" -ForegroundColor Cyan +Write-Host " Database.Port preserved: $($config2.Database.Port -eq 5433)" -ForegroundColor Cyan +Write-Host " AllowedOrigins count: $($config2.AllowedOrigins.Count)" -ForegroundColor Cyan +Write-Host "" + +# Demonstrate nested object serialization +Write-Host "Step 7: Create configuration from scratch" -ForegroundColor Green +$newConfig = [AppConfig]::new() +$newConfig.AppName = 'TestApp' +$newConfig.Version = '1.0.0' +$newConfig.Environment = 'development' +$newConfig.Database = [DatabaseConfig]::new() +$newConfig.Database.Host = 'localhost' +$newConfig.Database.Database = 'test_db' +$newConfig.Database.Username = 'test_user' +$newConfig.AllowedOrigins = @('http://localhost:3000', 'http://localhost:8080') + +$newYaml = ConvertTo-Yaml $newConfig + +Write-Host " Generated YAML from new object:" -ForegroundColor Yellow +Write-Host $newYaml +Write-Host "" diff --git a/examples/yamlkey-attribute.ps1 b/examples/yamlkey-attribute.ps1 new file mode 100644 index 0000000..9bdf59c --- /dev/null +++ b/examples/yamlkey-attribute.ps1 @@ -0,0 +1,54 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Example: Using YamlKey attribute for case-sensitive YAML keys + +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +# PowerShell class properties are case-insensitive, but YAML keys can be case-sensitive. +# Use the [YamlKey] attribute to map different YAML keys to different properties. + +# Load the class definition +. "$PSScriptRoot/classes/ServerConfig.ps1" + +# YAML with case-sensitive keys +$yaml = @" +Host: primary.example.com +host: backup.example.com +port: 8080 +"@ + +Write-Host "Input YAML:" +Write-Host $yaml +Write-Host "" + +# Deserialize +$config = $yaml | ConvertFrom-Yaml -As ([ServerConfig]) + +Write-Host "Deserialized values:" +Write-Host " PrimaryHost (from 'Host'): $($config.PrimaryHost)" +Write-Host " BackupHost (from 'host'): $($config.BackupHost)" +Write-Host " Port: $($config.Port)" +Write-Host "" + +# Modify and serialize back +$config.PrimaryHost = "new-primary.example.com" +$config.BackupHost = "new-backup.example.com" +$config.Port = 9090 + +$newYaml = $config | ConvertTo-Yaml + +Write-Host "Serialized YAML:" +Write-Host $newYaml diff --git a/lib/netstandard2.0/LICENSE.txt b/lib/netstandard2.0/LICENSE.txt deleted file mode 100644 index d4f2924..0000000 --- a/lib/netstandard2.0/LICENSE.txt +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2008, 2009, 2010, 2011, 2012, 2013, 2014 Antoine Aubry and contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/lib/netstandard2.0/PowerShellYaml.Module.dll b/lib/netstandard2.0/PowerShellYaml.Module.dll new file mode 100644 index 0000000000000000000000000000000000000000..9f2f27b58b5f5c6a2c5749ce5e3a7ccb4a6c2bd3 GIT binary patch literal 30720 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 literal 0 HcmV?d00001 diff --git a/lib/netstandard2.0/PowerShellYaml.dll b/lib/netstandard2.0/PowerShellYaml.dll new file mode 100644 index 0000000000000000000000000000000000000000..a15eed2447e47f31d1f5bef493d91ce0cc160982 GIT binary patch literal 15360 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®|b%G<0nH&*`^7^oRbo1 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 literal 0 HcmV?d00001 diff --git a/lib/netstandard2.0/PowerShellYamlSerializer.dll b/lib/netstandard2.0/PowerShellYamlSerializer.dll deleted file mode 100644 index 25b37f7bb7293f6c44dde2035ebf7b80a2c97073..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11264 zcmeHNeUKd0aql-XJNt3cakn~sf2}?_-N)Xyq!R+}8!d3BJKaej@FDH(&D|~9+gZ)b zN;>qx%0@OM!GKdh40ZyENfp5kC|0?^fDlwZG@ z{WuAc5Le|NGt%^Q_v_cMUw6NLZ)UImmitL1A`SP23q;T2$ybMjpA2$nE?@cGayk_} zz3N$Uh|ZlGTI;DE>?OKU zsPyj7mYvIw_K&nWQYIRSR)Z68xqsh*X9V|N+(Z=;S6sW9!S>7RF+lM7QfcoA7UloN zr$;gi*IgY%+ZovlenAN{yemgE2k70`5{=|}|C?u|V7inJfZkTpFX|YF9MEUMmviB{ zdUgYoR|Hl?E!#?flD6#yqT|jx4=7(9_#d^5lnFt$m2$XQR}JoxtAnVkgp}yBVDiPb zDdeO3*AxBcA|fHVpB}7Gs^=1gYHo9%%EEi=p={j(^=n6=QmfhG@zsk-*sIm7R@CZp z?tY7AUV&%5vYSh@2b=B_EoCl)J zeMG#X^SL#o`iESB8da%Rt7>(nnk9#nn!Yp3^0KPA0KA%8)M^Y-Yhr{@^1z?bEM+=x|0)!SWTwpK9(_8^i1Bb2T=PviDs7F(g+b3HOIw_^D#le7U~v7-63b&^)bNtJDwzHa*~mD8nHN1?u#`rGbH| zZfx$Shp!YNjt#HyM)Y>qtV*1Eu)b0?o4|D!y}F>b!lRqPtZo4a=}V%1ePuYhx^{Po z>=V~;Oj{vP*QG8t+kn)zm$b_TS9Z(~|M*R;icnoJFBbN%44Iq2n&0GCm&mRtHPxHJ zskz>QHJOz`yB$=Hd&|-!4ewGHnOneS;j37f8CyNZ)yyc&@sbv zl!Xh04){L^9>lm&^bOJ9 zYCK8|k|8<|JVZa1uoa`|^lf7P;sE1s!2WXD0ULC3q9M?4um0-3an-Pyt9+(APXF zD5<~nsJW6lB`F_OO3H^)e=qrIzND5&DnN@Qbq8I4BEP%E)z}3_V_!a60e1&!^gAw+_ z7Z<&+>yWs)KpD`KDNwhAy0buW+m{Oz+xf7hUZ#2426*a^3sf8O`xQw&MDs-kkzKKh zaQ#Fy2S>4@8a--x7uQ59+)TgLs?Qihkh?6*c#(-BF%Ou68Q*gy-` zaoYon{!9ODrIt2Gibq%@YN=ULEV)nAQFnnlETS}0BH2izC2dWVk<>$!)E`z_Xgjvm zd{j2mO_FlQ+f0dBZTlqk1+0p1D4WUls9O4_(oQ*#dX!EnTj*|&dX-*KxP}QzB9{fd zj@qfvPf)g0dJecoFX*4dZ_*UVFXF{PQ3*3X*1)(*-;sUefO(zFXPp%loSPLD3i9;- zvoDWz3iA?lg|w4x`#8=gxAytia<*TE{T0|eehBCzL(aw3e>K)>Uh}D-pA4*2gM#vY zUl&;Wyvh~>hG+v|1vN?7E@7vHF$sqx+$~`oP^THd`IMCSalobYIGt73(Q&!v7`D+% z)E!($CuzO1jh>?~ihjUf26qCcv{AZ8_>}}57iZP|^bXyiWa$B+hH~^(z!T^d(e|VD zef>A+0c7tZ;PAL_(hl(?y#)UE=%jc}|0~S<1V%Y8M?X)efjm#La}svaVS1W=Of~SSBIb*`#8cvVaaNoY zT8?wH^qP@twaD=)4N`#5OZlr@iDL!1XPZ24p~5ewd^Va(4YZH$q(2lV=nZivy^H&O zcaW{uwC~x|xTle}c4d>P@nOSC##6~V4C|UPkM9aJT}GgZ28a8N=`q8Kr6;DZ`PRSZ_KzZCG)~w9p>gK6X2Vue0Krsa;7s>2f`TnlCe6GxcTD ziM`Df>q*Li;?@i(uUE@udVzmUJ8kVZ$FnKp8W0H@p0OQcI$E^Yj$UV^G5rMXOdo_z zv55i07&peLKc30p%-WkCr?lbNPCPvxx5jDGaP|zFSu0`Cb%rxClQFvS!Hi<*1Li(F zdXjd=wButbgKoFYbhkO3i91OqcVyE}a@yb?+n@^^uYaFqPJ0}il{+(N8#eZ5jda3r zDbE(KDG^V_Ef>$)MqaWnWgaXriv-&{5T7<^Bt9vho$17sX-Tx(OxuWzoD$zd1Ls)=xf3;LFmBlf^&%C<;k8Z&2mP_EV-U|gnKCTt-MCZQ zR1n%}$0v;fMLot?c5;$^SL6+3Q>odUPTNM}rDjHwPO0EX+`?4*@LAJ1Xj=P<8uIde zux6KG*~rpM8##-uX++9Q$5WS;g1;t`lUZc)EG6z#C3A<7iXW1ged!>B77{Vx6dUrB zgI@mFSZv7KEYl}FQ*7{(D4$lzRMIJF?V5q(IpDkEsjNWT`!SeEqMojmGjbe6x+(}H4Id(e@ng_A0r;JpJbD>x=qT{I)?J(lw%%+>H zq+>|TU20H)*(4{^97Rs`ybb;FG@?Ikq@8GI)-he@m!?^M1nJIvuN}sOS88TeAR3m7 zqW1vQaV$&LPuy~f?7~8t#Wf1u28R)(jA1!5QcL$#-11aIW4=au6^_QZbQ%_QB`0I> z-Xze$VYj+%Gg8n(F}XS{%J3qw=%%DI#VZT(K7cV;fIT&vRn#_{pI1GbTT%}%YUrI# zIy^+76yys*pcZGy8Fkh-n`Gbx*?ZH7#Rn1@)o%CYF8l( z!0*o5sABFIC8m;@*%O=2q>_oGgId^=oWzsYlGMUAYb(k z#c7hZ;9Vw8)A)WDC6g?0>Zq9-@vj+eY09Dn-}VIMUcZg1@xkU8K5gaMN60~!EMBRO z&;-<^$d5ge`SaX=rXYo*pG=tgkb!Ksz4MT$o)ZLh)W z+O@c=(FbP`^eE~o)xuJjj+4ab6X?yYYe|&7S^m~DTbg=*^Ze`99VfogMOs9NfEpp; z15#PZWVuA!gFxqpdqp^g=4E9OO$qmhhr+S&PKgh>xb9cN@$je?q421mM0m6^_oAN^ zB|I7)g&sWp6+*0_aMBkN;oM8mRqi8EiQjVkfSO*J`yqqVS44CH99Vf}?o~z6F?=}p zx_^E+_m)z=47!$uN6Y2SvFxtk0>mM&q=HZgDF*hJshvh zLd^;2y9fW#9*Phc?=d5gM}m@fLd+v(Nnxm*N3J0cVxpC~ub1;ELzpOV%o;y$soV~W z;W-7ZaAp8DgmYgF$NWIbE04Rb&ARNz!!g<6xX-1Z2**Mg?psPY_h7i%$L24pXymCs znP;yc4X&$(RmX$ONB?;gHuBw0hO1_GIO%q%$_qRgu44stMY@sc#sE*4;~V(v!}Bhj zu77JNaBgw$eOu>0eOsXEPdD%S*2aN0L8`vor|9^vXnHvJfU1W#m+5)a!qo*FW)yIY zSb{flQu#Qkmiv$z%a&DyM}rY{SvZLxPKOY~aE#-*lV`6e_!cDQkPj2Ze+8F6g4x48 z{HzM+PV%$PqZ{$WcXoXHz_o(EsE#NLZo(VRfSK++#MKN%b!5si58489ij=!f(BkMo z?+9v-eBG(@PKZ|@XpS~QLwQxy4-mYpX>$saZ)x= zK3$>-KOx{BQ479ZNut(hZ62R!O2pe+H%)9BOKfgxG1@o9H@3HL8Z#1YjYjK6!)P5h z#_&O3el0FTJa`HMFSzpC{(3=o?c>i~Bg$MHX3T^A*_4ya;N5CO3C7Y>*j`aW31TFUd4sORJ=%eLHIS04^xd7=JBE9P zjlXG`|3nvUyD#v$XYQb~4%X2yWlkFnconjeMyi3|XBrYIGdn&8k2PfY9B%VDynY6! z#|HfQL4!GVdxPiEhLX2cM55txVTfkN$7pz4XLH*|@@$yO+raZoXSUw`!J{{SeaFdv z*#6B9w${B~?!YtR;upjG-zvELJ=j2dQmKBN7iro~U^g}7y8^dfSO*;!yW?VYpV60i zfPc>*{31cQ*-h>*a#%r^@L6sf?wj`#osvt0IgH~+S=vRzv zzq+9pwSW7_&1}w0zT78LUtavN$t@lp1CJiu0$SrZ6!hWiXbRhNOm4TP6k3Zf&-~Xq z79`R*5~T2SAjiiA_j*RF;%Wz*_+Y_X_2kEB`Z)#`@{GdgltyZ$F+i*`_O`rlqyT8Gc$DqBJgj!1Js|G*J_w zcT#F)FJ^Fb%df;NNO0-1KuaoUI{V`JbXs0}gW z-l(tgISx1m&k=fYygQLWd`zAOHw6zzuwpp(ijgnc!SVm>`nB{xhx<1UKl&^Iel5fQ E-xjG46aWAK diff --git a/lib/netstandard2.0/YamlDotNet.dll b/lib/netstandard2.0/YamlDotNet.dll old mode 100644 new mode 100755 diff --git a/powershell-yaml.psd1 b/powershell-yaml.psd1 index b0610ca..593938c 100644 --- a/powershell-yaml.psd1 +++ b/powershell-yaml.psd1 @@ -1,4 +1,4 @@ -# Copyright 2016-2024 Cloudbase Solutions Srl +# Copyright 2016-2026 Cloudbase Solutions Srl # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -25,13 +25,35 @@ RootModule = 'powershell-yaml.psm1' # Version number of this module. -ModuleVersion = '0.4.12' +ModuleVersion = '0.5.0' PrivateData = @{ PSData = @{ + Prerelease = 'beta1' LicenseUri = 'https://github.com/cloudbase/powershell-yaml/blob/master/LICENSE' ProjectUri = 'https://github.com/cloudbase/powershell-yaml' ReleaseNotes = @' +# 0.5.0 + +New Features: +* Type-safe YAML deserialization using PowerShell classes inheriting from YamlBase +* Round-trip preservation of comments, tags, and scalar styles +* [YamlKey] attribute to map custom YAML keys to properties +* Duplicate key detection prevents silent data loss +* Enhanced PSCustomObject mode with metadata preservation +* Validation for non-YamlBase nested classes with clear error messages +* Automatic property name conversion (PascalCase -> hyphenated-case) + +Usage: + # PSCustomObject mode with metadata + $obj = $yaml | ConvertFrom-Yaml -As ([PSCustomObject]) + + # Typed mode with YamlBase classes + class MyConfig : YamlBase { [string]$Name } + $config = $yaml | ConvertFrom-Yaml -As ([MyConfig]) + +See examples/ for detailed usage patterns. + # 0.4.12 Bugfixes: @@ -74,7 +96,7 @@ Author = 'Gabriel Adrian Samfira','Alessandro Pilotti' CompanyName = 'Cloudbase Solutions SRL' # Copyright statement for this module -Copyright = '(c) 2016-2024 Cloudbase Solutions SRL. All rights reserved.' +Copyright = '(c) 2016-2026 Cloudbase Solutions SRL. All rights reserved.' # Description of the functionality provided by this module Description = 'Powershell module for serializing and deserializing YAML' @@ -82,8 +104,34 @@ Description = 'Powershell module for serializing and deserializing YAML' # Minimum version of the Windows PowerShell engine required by this module PowerShellVersion = '5.0' +# Load PowerShellYaml.dll before parsing the module +# This makes YamlBase available for class inheritance in user scripts +RequiredAssemblies = @('lib/netstandard2.0/PowerShellYaml.dll') + # Functions to export from this module -FunctionsToExport = "ConvertTo-Yaml","ConvertFrom-Yaml" +FunctionsToExport = @( + "ConvertTo-Yaml", + "ConvertFrom-Yaml", + "Set-YamlPropertyComment", + "Get-YamlPropertyComment", + "Set-YamlPropertyScalarStyle", + "Test-YamlMetadata" +) + +# Cmdlets to export from this module +# We don't list any here - the .psm1's Export-ModuleMember controls what gets exported. +# ConvertFrom-YamlTyped and ConvertTo-YamlTyped are loaded internally but not exported. +# CmdletsToExport = @() AliasesToExport = "cfy","cty" + +# List of all files packaged with this module +FileList = @( + 'powershell-yaml.psd1', + 'powershell-yaml.psm1', + 'lib/netstandard2.0/YamlDotNet.dll', + 'lib/netstandard2.0/PowerShellYamlSerializer.dll', + 'lib/netstandard2.0/PowerShellYaml.dll', + 'lib/netstandard2.0/PowerShellYaml.Module.dll' +) } diff --git a/powershell-yaml.psm1 b/powershell-yaml.psm1 index dfecf6a..da26d1a 100644 --- a/powershell-yaml.psm1 +++ b/powershell-yaml.psm1 @@ -1,4 +1,4 @@ -# Copyright 2016-2024 Cloudbase Solutions Srl +# Copyright 2016-2026 Cloudbase Solutions Srl # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -25,65 +25,113 @@ enum SerializationOptions { OmitNullValues = 64 UseFlowStyle = 128 UseSequenceFlowStyle = 256 + UseBlockStyle = 512 + UseSequenceBlockStyle = 1024 } + $here = Split-Path -Parent $MyInvocation.MyCommand.Path $infinityRegex = [regex]::new('^[-+]?(\.inf|\.Inf|\.INF)$', 'Compiled, CultureInvariant'); -function Invoke-LoadFile { +function Invoke-LoadAssemblyWithDependencies { param( - [string]$assemblyPath + [Parameter(Mandatory)] + [string]$MainAssemblyPath, + + [Parameter(Mandatory)] + [hashtable]$Dependencies, + + [string]$ForceLoadTypeName ) - $powershellYamlDotNetAssemblyPath = Join-Path $assemblyPath 'YamlDotNet.dll' - $serializerAssemblyPath = Join-Path $assemblyPath 'PowerShellYamlSerializer.dll' - $yamlAssembly = [Reflection.Assembly]::LoadFile($powershellYamlDotNetAssemblyPath) - $serializerAssembly = [Reflection.Assembly]::LoadFile($serializerAssemblyPath) + # Load the main assembly via LoadFile (creates anonymous ALC on PS 7+) + $mainAssembly = [Reflection.Assembly]::LoadFile($MainAssemblyPath) if ($PSVersionTable['PSEdition'] -eq 'Core') { - # Register the AssemblyResolve event to load dependencies manually. This seems to be needed only on - # PowerShell Core. + # On PowerShell Core, use AssemblyResolve to manually load dependencies + # This is needed because LoadFile doesn't automatically resolve dependencies $resolver = { param ($snd, $e) - # This event only needs to run once when the Invoke-LoadFile function is called. - # If it's called again, the variables defined in this functions will not be available, - # so we can safely ignore the event. - if (-not $serializerAssemblyPath -or -not $powershellYamlDotNetAssemblyPath) { + + # Only respond if we have the necessary paths (from outer scope) + if (-not $MainAssemblyPath -or -not $Dependencies) { return $null } - # Load YamlDotNet if it's requested by PowerShellYamlSerializer. Ignore other requests as they might - # originate from other assemblies that are not part of this module and which might have different - # versions of the module that they need to load. - if ($e.Name -match '^YamlDotNet,*' -and $e.RequestingAssembly.Location -eq $serializerAssemblyPath) { - return [System.Reflection.Assembly]::LoadFile($powershellYamlDotNetAssemblyPath) + + # Only respond to requests from our main assembly + if ($e.RequestingAssembly.Location -eq $MainAssemblyPath) { + # Check each dependency + foreach ($dep in $Dependencies.GetEnumerator()) { + if ($e.Name -match "^$($dep.Key),") { + # Dependency can be either a path (string) or an Assembly object + if ($dep.Value -is [string]) { + return [System.Reflection.Assembly]::LoadFile($dep.Value) + } else { + return $dep.Value + } + } + } } return $null } + [System.AppDomain]::CurrentDomain.add_AssemblyResolve($resolver) - # Load the StringQuotingEmitter from PowerShellYamlSerializer to force the resolver handler to fire once. - # This is an ugly hack I am not happy with. - $serializerAssembly.GetType('StringQuotingEmitter') | Out-Null - # Remove the resolver handler after it has been used. + # Force dependency resolution by accessing a type + if ($ForceLoadTypeName) { + $mainAssembly.GetType($ForceLoadTypeName) | Out-Null + } else { + $mainAssembly.GetTypes() | Out-Null + } + [System.AppDomain]::CurrentDomain.remove_AssemblyResolve($resolver) } - return @{ 'yaml' = $yamlAssembly; 'quoted' = $serializerAssembly } + return $mainAssembly } -function Invoke-LoadAssembly { - $libDir = Join-Path $here 'lib' - $assemblies = @{ - 'netstandard2.0' = Join-Path $libDir 'netstandard2.0'; - } +# ============================================================================ +# Assembly Path Configuration +# ============================================================================ - return (Invoke-LoadFile -assemblyPath $assemblies['netstandard2.0']) -} +$libDir = Join-Path $here 'lib/netstandard2.0' + +# YAML module assemblies +$yamlDotNetPath = Join-Path $libDir 'YamlDotNet.dll' + +# Typed YAML module assemblies (now contains ALL serialization code) +$typedModulePath = Join-Path $libDir 'PowerShellYaml.Module.dll' +$typedYamlBasePath = Join-Path $libDir 'PowerShellYaml.dll' + +# Load YamlDotNet first (isolated via LoadFile) +$yamlDotNetAssembly = [Reflection.Assembly]::LoadFile($yamlDotNetPath) + +# Load PowerShellYaml.dll (must be in Default ALC for class inheritance) +$yamlBaseAsm = [System.Reflection.Assembly]::LoadFrom($typedYamlBasePath) -$assemblies = Invoke-LoadAssembly +# Load PowerShellYaml.Module.dll early for BuilderUtils access +# Dependencies: YamlDotNet (isolated) and PowerShellYaml (from Default ALC) +$script:typedModuleAssembly = Invoke-LoadAssemblyWithDependencies ` + -MainAssemblyPath $typedModulePath ` + -Dependencies @{ + 'YamlDotNet' = $yamlDotNetPath + 'PowerShellYaml' = $yamlBaseAsm + } -$yamlDotNetAssembly = $assemblies['yaml'] -$stringQuotedAssembly = $assemblies['quoted'] +# Store types for use throughout the module +$script:BuilderUtils = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.BuilderUtils') +$script:TypedYamlConverter = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.TypedYamlConverter') +$script:YamlDocumentParser = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.YamlDocumentParser') +$script:PSObjectMetadataExtensions = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.PSObjectMetadataExtensions') +$YamlMetadataStore = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.YamlMetadataStore') +$MetadataAwareSerializer = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.MetadataAwareSerializer') + +# Create type accelerators for test scripts +$TypeAcceleratorsClass = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') +$TypeAcceleratorsClass::Add('YamlDocumentParser', $script:YamlDocumentParser) +$TypeAcceleratorsClass::Add('PSObjectMetadataExtensions', $script:PSObjectMetadataExtensions) +$TypeAcceleratorsClass::Add('YamlMetadataStore', $YamlMetadataStore) +$TypeAcceleratorsClass::Add('MetadataAwareSerializer', $MetadataAwareSerializer) function Get-YamlDocuments { [CmdletBinding()] @@ -273,7 +321,7 @@ function Convert-YamlDocumentToPSObject { [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [System.Object]$Node, + [System.Object]$Node, [switch]$Ordered ) process { @@ -392,7 +440,21 @@ function ConvertFrom-Yaml { [string]$Yaml, [switch]$AllDocuments = $false, [switch]$Ordered, - [switch]$UseMergingParser = $false + [switch]$UseMergingParser = $false, + [Parameter(Mandatory = $false)] + [ValidateScript({ + if ($null -eq $_) { + throw "The -As parameter cannot be null" + } + if ($_ -eq [PSCustomObject]) { + return $true + } + if ($_.IsSubclassOf([PowerShellYaml.YamlBase])) { + return $true + } + throw "The -As parameter must be either [PSCustomObject] or a type that inherits from [PowerShellYaml.YamlBase]. Got: $($_.FullName)" + })] + [type]$As ) begin { @@ -406,14 +468,32 @@ function ConvertFrom-Yaml { if ($d -eq '') { return } + + # Route based on -As parameter (validation already done by ValidateScript) + if ($PSBoundParameters.ContainsKey('As')) { + if ($As.IsSubclassOf([PowerShellYaml.YamlBase])) { + if ($script:TypedYamlConverter) { + return $script:TypedYamlConverter::FromYaml($d, $As) + } else { + throw "Typed YAML module not loaded" + } + } else { + # Use YamlDocumentParser to preserve metadata + $result = $script:YamlDocumentParser::ParseWithMetadata($d) + if ($null -eq $result.Item1) { + return $null + } + # Create enhanced PSCustomObject from parsed data and metadata + return $script:PSObjectMetadataExtensions::CreateEnhancedPSCustomObject($result.Item1, $result.Item2) + } + } + + # Mode 1: Original hashtable mode (no -As parameter) $documents = Get-YamlDocuments -Yaml $d -UseMergingParser:$UseMergingParser if (!$documents.Count) { return } - if ($documents.Count -eq 1) { - return Convert-YamlDocumentToPSObject $documents[0].RootNode -Ordered:$Ordered - } - if (!$AllDocuments) { + if (($documents.Count -eq 1) -or !$AllDocuments) { return Convert-YamlDocumentToPSObject $documents[0].RootNode -Ordered:$Ordered } $ret = @() @@ -428,7 +508,7 @@ function Get-Serializer { param( [Parameter(Mandatory = $true)][SerializationOptions]$Options ) - + $builder = $yamlDotNetAssembly.GetType('YamlDotNet.Serialization.SerializerBuilder')::new() $JsonCompatible = $Options.HasFlag([SerializationOptions]::JsonCompatible) @@ -455,9 +535,10 @@ function Get-Serializer { $omitNull = $Options.HasFlag([SerializationOptions]::OmitNullValues) $useFlowStyle = $Options.HasFlag([SerializationOptions]::UseFlowStyle) $useSequenceFlowStyle = $Options.HasFlag([SerializationOptions]::UseSequenceFlowStyle) + $useBlockStyle = $Options.HasFlag([SerializationOptions]::UseBlockStyle) + $useSequenceBlockStyle = $Options.HasFlag([SerializationOptions]::UseSequenceBlockStyle) - $stringQuoted = $stringQuotedAssembly.GetType('BuilderUtils') - $builder = $stringQuoted::BuildSerializer($builder, $omitNull, $useFlowStyle, $useSequenceFlowStyle, $JsonCompatible) + $builder = $script:BuilderUtils::BuildSerializer($builder, $omitNull, $useFlowStyle, $useSequenceFlowStyle, $useBlockStyle, $useSequenceBlockStyle, $JsonCompatible) return $builder.Build() } @@ -478,7 +559,12 @@ function ConvertTo-Yaml { [switch]$KeepArray, - [switch]$Force + [switch]$Force, + + # Typed YAML parameters (for YamlBase objects) + [switch]$OmitNull, + + [switch]$EmitTags ) begin { $d = [System.Collections.Generic.List[object]](New-Object 'System.Collections.Generic.List[object]') @@ -495,8 +581,58 @@ function ConvertTo-Yaml { if ($d.Count -eq 1 -and !($KeepArray)) { $d = $d[0] } - $norm = Convert-PSObjectToGenericObject $d - if ($OutFile) { + + # Mode 3: Typed class mode - call C# helper directly + if ($d -is [PowerShellYaml.YamlBase]) { + if ($script:TypedYamlConverter) { + # Extract flow/block style options if using Options parameter + $useFlowStyle = $false + $useBlockStyle = $false + $useSequenceFlowStyle = $false + $useSequenceBlockStyle = $false + $indentedSequences = $false + if ($PSCmdlet.ParameterSetName -eq 'Options') { + $useFlowStyle = $Options.HasFlag([SerializationOptions]::UseFlowStyle) + $useBlockStyle = $Options.HasFlag([SerializationOptions]::UseBlockStyle) + $useSequenceFlowStyle = $Options.HasFlag([SerializationOptions]::UseSequenceFlowStyle) + $useSequenceBlockStyle = $Options.HasFlag([SerializationOptions]::UseSequenceBlockStyle) + $indentedSequences = $Options.HasFlag([SerializationOptions]::WithIndentedSequences) + } + $yaml = $script:TypedYamlConverter::ToYaml($d, $OmitNull.IsPresent, $EmitTags.IsPresent, $useFlowStyle, $useBlockStyle, $useSequenceFlowStyle, $useSequenceBlockStyle, $indentedSequences) + } else { + throw "Typed YAML module not loaded" + } + } elseif ($script:PSObjectMetadataExtensions::IsEnhancedPSCustomObject($d)) { + # Use metadata-aware serializer + $MetadataAwareSerializer = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.MetadataAwareSerializer') + # Extract indentedSequences option if using Options parameter + $indentedSequences = $false + if ($PSCmdlet.ParameterSetName -eq 'Options') { + $indentedSequences = $Options.HasFlag([SerializationOptions]::WithIndentedSequences) + } + $yaml = $MetadataAwareSerializer::Serialize($d, $indentedSequences, $EmitTags.IsPresent) + } else { + $wrt = New-Object 'System.IO.StringWriter' + $norm = Convert-PSObjectToGenericObject $d + if ($PSCmdlet.ParameterSetName -eq 'NoOptions') { + $Options = 0 + if ($JsonCompatible) { + # No indent options :~( + $Options = [SerializationOptions]::JsonCompatible + } + } + try { + $serializer = Get-Serializer $Options + $serializer.Serialize($wrt, $norm) + $yaml = $wrt.ToString() + } finally { + if ($null -ne $wrt) { + $wrt.Dispose() + } + } + } + + if ($OutFile) { $parent = Split-Path $OutFile if (!(Test-Path $parent)) { throw 'Parent folder for specified path does not exist' @@ -504,40 +640,164 @@ function ConvertTo-Yaml { if ((Test-Path $OutFile) -and !$Force) { throw 'Target file already exists. Use -Force to overwrite.' } + [System.IO.File]::WriteAllText($OutFile, $yaml) + return } + return $yaml + } +} - if ($PSCmdlet.ParameterSetName -eq 'NoOptions') { - $Options = 0 - if ($JsonCompatible) { - # No indent options :~( - $Options = [SerializationOptions]::JsonCompatible - } +<# +.SYNOPSIS + Sets a YAML comment for a property on an enhanced PSCustomObject. +.DESCRIPTION + Adds or updates a comment that will be written above the property when + converting back to YAML. The object must be created with ConvertFrom-Yaml -As [PSCustomObject]. +.PARAMETER InputObject + The enhanced PSCustomObject with YAML metadata. +.PARAMETER PropertyName + The name of the property to add a comment to. +.PARAMETER Comment + The comment text (without # prefix). +.EXAMPLE + $config = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $config | Set-YamlPropertyComment -PropertyName 'Server' -Comment 'Production server address' +#> +function Set-YamlPropertyComment { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [PSCustomObject]$InputObject, + + [Parameter(Mandatory)] + [string]$PropertyName, + + [Parameter(Mandatory)] + [string]$Comment + ) + + process { + $metadata = $script:PSObjectMetadataExtensions::GetMetadata($InputObject) + if ($metadata) { + $metadata.SetPropertyComment($PropertyName, $Comment) + } else { + Write-Warning "Object does not have YAML metadata. Use ConvertFrom-Yaml with -As [PSCustomObject]" } + } +} - if ($OutFile) { - $wrt = New-Object 'System.IO.StreamWriter' $OutFile +<# +.SYNOPSIS + Gets a YAML comment for a property on an enhanced PSCustomObject. +.DESCRIPTION + Retrieves the comment associated with a property that was preserved during + YAML parsing or set with Set-YamlPropertyComment. +.PARAMETER InputObject + The enhanced PSCustomObject with YAML metadata. +.PARAMETER PropertyName + The name of the property to get the comment for. +.EXAMPLE + $comment = Get-YamlPropertyComment -InputObject $config -PropertyName 'Server' +#> +function Get-YamlPropertyComment { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [PSCustomObject]$InputObject, + + [Parameter(Mandatory)] + [string]$PropertyName + ) + + process { + $metadata = $script:PSObjectMetadataExtensions::GetMetadata($InputObject) + if ($metadata) { + return $metadata.GetPropertyComment($PropertyName) } else { - $wrt = New-Object 'System.IO.StringWriter' + Write-Warning "Object does not have YAML metadata. Use ConvertFrom-Yaml with -As [PSCustomObject]" + return $null } + } +} - try { - $serializer = Get-Serializer $Options - $serializer.Serialize($wrt, $norm) +<# +.SYNOPSIS + Sets the scalar style for a property on an enhanced PSCustomObject. +.DESCRIPTION + Controls how a property value will be formatted when converting to YAML + (e.g., plain, single-quoted, double-quoted, literal, folded). +.PARAMETER InputObject + The enhanced PSCustomObject with YAML metadata. +.PARAMETER PropertyName + The name of the property to set the style for. +.PARAMETER Style + The scalar style to use (Plain, SingleQuoted, DoubleQuoted, Literal, Folded). +.EXAMPLE + $config | Set-YamlPropertyScalarStyle -PropertyName 'Description' -Style Literal +#> +function Set-YamlPropertyScalarStyle { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [PSCustomObject]$InputObject, - if ($OutFile) { - return - } else { - return $wrt.ToString() - } - } finally { - if ($null -ne $wrt) { - $wrt.Dispose() - } + [Parameter(Mandatory)] + [string]$PropertyName, + + [Parameter(Mandatory)] + [ValidateSet('Plain', 'SingleQuoted', 'DoubleQuoted', 'Literal', 'Folded')] + [string]$Style + ) + + process { + $metadata = $script:PSObjectMetadataExtensions::GetMetadata($InputObject) + if ($metadata) { + $scalarStyle = [YamlDotNet.Core.ScalarStyle]::$Style + $metadata.SetPropertyScalarStyle($PropertyName, $scalarStyle) + } else { + Write-Warning "Object does not have YAML metadata. Use ConvertFrom-Yaml with -As [PSCustomObject]" } } } +<# +.SYNOPSIS + Tests if a PSCustomObject has YAML metadata attached. +.DESCRIPTION + Returns $true if the object was created with ConvertFrom-Yaml -As [PSCustomObject] + and has metadata support, $false otherwise. +.PARAMETER InputObject + The PSCustomObject to test. +.EXAMPLE + if (Test-YamlMetadata $config) { + $config | Set-YamlPropertyComment -PropertyName 'Name' -Comment 'User name' + } +#> +function Test-YamlMetadata { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [PSCustomObject]$InputObject + ) + + process { + return $script:PSObjectMetadataExtensions::IsEnhancedPSCustomObject($InputObject) + } +} + +# Typed YAML Module already loaded at module initialization (see lines 107-123) + New-Alias -Name cfy -Value ConvertFrom-Yaml New-Alias -Name cty -Value ConvertTo-Yaml -Export-ModuleMember -Function ConvertFrom-Yaml, ConvertTo-Yaml -Alias cfy, cty +# Export only the public API +# Typed cmdlets (ConvertFrom-YamlTyped, ConvertTo-YamlTyped) are loaded but not exported. +# The manifest (.psd1) controls the final export list. +Export-ModuleMember -Function @( + 'ConvertFrom-Yaml', + 'ConvertTo-Yaml', + 'Set-YamlPropertyComment', + 'Get-YamlPropertyComment', + 'Set-YamlPropertyScalarStyle', + 'Test-YamlMetadata' +) -Alias @('cfy', 'cty') diff --git a/powershell-yaml.sln b/powershell-yaml.sln index f2d0133..4601421 100644 --- a/powershell-yaml.sln +++ b/powershell-yaml.sln @@ -2,7 +2,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerShellYamlSerializer", "src\PowerShellYamlSerializer.csproj", "{661F072A-C59E-7ABB-EB12-C69FF3B9328F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerShellYaml", "src\PowerShellYaml\PowerShellYaml.csproj", "{661F072A-C59E-7ABB-EB12-C69FF3B9328F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerShellYaml.Module", "src\PowerShellYaml.Module\PowerShellYaml.Module.csproj", "{8B2C1D5E-3F4A-4B9D-8C6E-1A2B3C4D5E6F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -14,6 +16,10 @@ Global {661F072A-C59E-7ABB-EB12-C69FF3B9328F}.Debug|Any CPU.Build.0 = Debug|Any CPU {661F072A-C59E-7ABB-EB12-C69FF3B9328F}.Release|Any CPU.ActiveCfg = Release|Any CPU {661F072A-C59E-7ABB-EB12-C69FF3B9328F}.Release|Any CPU.Build.0 = Release|Any CPU + {8B2C1D5E-3F4A-4B9D-8C6E-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B2C1D5E-3F4A-4B9D-8C6E-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B2C1D5E-3F4A-4B9D-8C6E-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B2C1D5E-3F4A-4B9D-8C6E-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/PowerShellYaml.Module/LegacySerializers.cs b/src/PowerShellYaml.Module/LegacySerializers.cs new file mode 100644 index 0000000..05aa9b0 --- /dev/null +++ b/src/PowerShellYaml.Module/LegacySerializers.cs @@ -0,0 +1,1016 @@ +// Copyright 2016-2026 Cloudbase Solutions Srl +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// + +#nullable disable + +namespace PowerShellYaml.Module; + +using System; +using System.IO; +using System.Numerics; +using System.Text.RegularExpressions; +using System.Collections; +using System.Management.Automation; +using System.Collections.Generic; +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.EventEmitters; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization.NamingConventions; +using YamlDotNet.Serialization.ObjectGraphVisitors; +using YamlDotNet.RepresentationModel; + +public sealed class NullValueGraphVisitor : ChainedObjectGraphVisitor +{ + public NullValueGraphVisitor(IObjectGraphVisitor nextVisitor) + : base(nextVisitor) + { + } + + public override bool EnterMapping(IPropertyDescriptor key, IObjectDescriptor value, IEmitter context, ObjectSerializer serializer) { + if (value.Value == null) { + return false; + } + return base.EnterMapping(key, value, context, serializer); + } + + public override bool EnterMapping(IObjectDescriptor key, IObjectDescriptor value, IEmitter context, ObjectSerializer serializer) { + if (value.Value == null) { + return false; + } + return base.EnterMapping(key, value, context, serializer); + } +} + +internal static class PSObjectHelper { + /// + /// Unwraps a PSObject to its BaseObject if the BaseObject is not a PSCustomObject. + /// + /// The object to potentially unwrap + /// The type of the unwrapped object + /// The unwrapped object if it was a PSObject wrapping a non-PSCustomObject, otherwise the original object + public static object UnwrapIfNeeded(object obj, out Type unwrappedType) { + if (obj is PSObject psObj && psObj.BaseObject != null) { + var baseType = psObj.BaseObject.GetType(); + if (baseType != typeof(System.Management.Automation.PSCustomObject)) { + unwrappedType = baseType; + return psObj.BaseObject; + } + } + unwrappedType = obj?.GetType(); + return obj; + } +} + +public class BigIntegerTypeConverter : IYamlTypeConverter { + public bool Accepts(Type type) { + return typeof(BigInteger).IsAssignableFrom(type); + } + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { + var value = parser.Consume().Value; + var bigNr = BigInteger.Parse(value); + return bigNr; + } + + public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { + var bigNr = (BigInteger)value; + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, bigNr.ToString(), ScalarStyle.Plain, true, false)); + } +} + +public class IDictionaryTypeConverter : IYamlTypeConverter { + + private bool omitNullValues; + private bool useFlowStyle; + + public IDictionaryTypeConverter(bool omitNullValues = false, bool useFlowStyle = false) { + this.omitNullValues = omitNullValues; + this.useFlowStyle = useFlowStyle; + } + + public bool Accepts(Type type) { + return typeof(IDictionary).IsAssignableFrom(type); + } + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { + var deserializedObject = rootDeserializer(typeof(IDictionary)) as IDictionary; + return deserializedObject; + } + + public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { + var hObj = (IDictionary)value; + var mappingStyle = this.useFlowStyle ? MappingStyle.Flow : MappingStyle.Block; + + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); + foreach (DictionaryEntry entry in hObj) { + if(entry.Value == null) { + if (this.omitNullValues) { + continue; + } + serializer(entry.Key, entry.Key.GetType()); + emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); + continue; + } + serializer(entry.Key, entry.Key.GetType()); + var unwrapped = PSObjectHelper.UnwrapIfNeeded(entry.Value, out var unwrappedType); + serializer(unwrapped, unwrappedType); + } + emitter.Emit(new MappingEnd()); + } +} + +public class PSObjectTypeConverter : IYamlTypeConverter { + + private bool omitNullValues; + private bool useFlowStyle; + + public PSObjectTypeConverter(bool omitNullValues = false, bool useFlowStyle = false) { + this.omitNullValues = omitNullValues; + this.useFlowStyle = useFlowStyle; + } + + public bool Accepts(Type type) { + return typeof(PSObject).IsAssignableFrom(type); + } + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + // We don't really need to do any custom deserialization. + var deserializedObject = rootDeserializer(typeof(IDictionary)) as IDictionary; + return deserializedObject; + } + + public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { + var psObj = (PSObject)value; + if (psObj.BaseObject != null && + !typeof(IDictionary).IsAssignableFrom(psObj.BaseObject.GetType()) && + !typeof(PSCustomObject).IsAssignableFrom(psObj.BaseObject.GetType())) { + serializer(psObj.BaseObject, psObj.BaseObject.GetType()); + return; + } + var mappingStyle = this.useFlowStyle ? MappingStyle.Flow : MappingStyle.Block; + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); + foreach (var prop in psObj.Properties) { + if (prop.Value == null) { + if (this.omitNullValues) { + continue; + } + serializer(prop.Name, prop.Name.GetType()); + emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); + } else { + serializer(prop.Name, prop.Name.GetType()); + var unwrapped = PSObjectHelper.UnwrapIfNeeded(prop.Value, out var unwrappedType); + serializer(unwrapped, unwrappedType); + } + } + emitter.Emit(new MappingEnd()); + } +} + +public class StringQuotingEmitter: ChainedEventEmitter { + // Patterns from https://yaml.org/spec/1.2/spec.html#id2804356 + private static Regex quotedRegex = new Regex(@"^(\~|null|true|false|on|off|yes|no|y|n|[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?|[-+]?(\.inf))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public StringQuotingEmitter(IEventEmitter next): base(next) {} + + public override void Emit(ScalarEventInfo eventInfo, IEmitter emitter) { + var typeCode = eventInfo.Source.Value != null + ? Type.GetTypeCode(eventInfo.Source.Type) + : TypeCode.Empty; + + switch (typeCode) { + case TypeCode.Char: + if (Char.IsDigit((char)eventInfo.Source.Value)) { + eventInfo.Style = ScalarStyle.DoubleQuoted; + } + break; + case TypeCode.String: + var val = eventInfo.Source.Value.ToString(); + if (quotedRegex.IsMatch(val)) + { + eventInfo.Style = ScalarStyle.DoubleQuoted; + } else if (val.IndexOf('\n') > -1) { + eventInfo.Style = ScalarStyle.Literal; + } + break; + } + + base.Emit(eventInfo, emitter); + } +} + +public class FlowStyleAllEmitter(IEventEmitter next) : ChainedEventEmitter(next) { + public override void Emit(MappingStartEventInfo eventInfo, IEmitter emitter) { + eventInfo.Style = MappingStyle.Flow; + base.Emit(eventInfo, emitter); + } + + public override void Emit(SequenceStartEventInfo eventInfo, IEmitter emitter){ + eventInfo.Style = SequenceStyle.Flow; + base.Emit(eventInfo, emitter); + } +} + +public class FlowStyleSequenceEmitter(IEventEmitter next) : ChainedEventEmitter(next) { + public override void Emit(SequenceStartEventInfo eventInfo, IEmitter emitter){ + eventInfo.Style = SequenceStyle.Flow; + base.Emit(eventInfo, emitter); + } +} + +public class BuilderUtils { + public static SerializerBuilder BuildSerializer( + SerializerBuilder builder, + bool omitNullValues = false, + bool useFlowStyle = false, + bool useSequenceFlowStyle = false, + bool useBlockStyle = false, + bool useSequenceBlockStyle = false, + bool jsonCompatible = false) { + + if (jsonCompatible) { + useFlowStyle = true; + useSequenceFlowStyle = true; + } + + // Block style takes precedence over flow style if both are set + if (useBlockStyle) { + useFlowStyle = false; + } + if (useSequenceBlockStyle) { + useSequenceFlowStyle = false; + } + + builder = builder + .WithEventEmitter(next => new StringQuotingEmitter(next)) + .WithTypeConverter(new BigIntegerTypeConverter()) + .WithTypeConverter(new IDictionaryTypeConverter(omitNullValues, useFlowStyle)) + .WithTypeConverter(new PSObjectTypeConverter(omitNullValues, useFlowStyle)); + if (omitNullValues) { + builder = builder + .WithEmissionPhaseObjectGraphVisitor(args => new NullValueGraphVisitor(args.InnerVisitor)); + } + if (useFlowStyle) { + builder = builder.WithEventEmitter(next => new FlowStyleAllEmitter(next)); + } + if (useSequenceFlowStyle) { + builder = builder.WithEventEmitter(next => new FlowStyleSequenceEmitter(next)); + } + + return builder; + } +} + +/// +/// Metadata storage for individual YAML properties +/// +public class YamlPropertyMetadata { + public string Comment { get; set; } + public string Tag { get; set; } + public string Anchor { get; set; } + public string Alias { get; set; } + public ScalarStyle? ScalarStyle { get; set; } + public MappingStyle? MappingStyle { get; set; } + public SequenceStyle? SequenceStyle { get; set; } +} + +/// +/// Metadata store for an object/document +/// +public class YamlMetadataStore { + private readonly Dictionary _propertyMetadata = new Dictionary(); + private readonly Dictionary _nestedObjectMetadata = new Dictionary(); + + public string DocumentComment { get; set; } + public MappingStyle? DocumentMappingStyle { get; set; } + + public void SetPropertyComment(string propertyName, string comment) { + GetOrCreatePropertyMetadata(propertyName).Comment = comment; + } + + public string GetPropertyComment(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.Comment + : null; + } + + public void SetPropertyStyle(string propertyName, MappingStyle style) { + GetOrCreatePropertyMetadata(propertyName).MappingStyle = style; + } + + public MappingStyle? GetPropertyStyle(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.MappingStyle + : null; + } + + public void SetPropertyScalarStyle(string propertyName, ScalarStyle style) { + GetOrCreatePropertyMetadata(propertyName).ScalarStyle = style; + } + + public ScalarStyle? GetPropertyScalarStyle(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.ScalarStyle + : null; + } + + public void SetPropertyMappingStyle(string propertyName, MappingStyle style) { + GetOrCreatePropertyMetadata(propertyName).MappingStyle = style; + } + + public MappingStyle? GetPropertyMappingStyle(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.MappingStyle + : null; + } + + public void SetPropertySequenceStyle(string propertyName, SequenceStyle style) { + GetOrCreatePropertyMetadata(propertyName).SequenceStyle = style; + } + + public SequenceStyle? GetPropertySequenceStyle(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.SequenceStyle + : null; + } + + public void SetPropertyTag(string propertyName, string tag) { + GetOrCreatePropertyMetadata(propertyName).Tag = tag; + } + + public string GetPropertyTag(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.Tag + : null; + } + + public YamlMetadataStore GetNestedMetadata(string propertyName) { + if (!_nestedObjectMetadata.TryGetValue(propertyName, out var metadata)) { + metadata = new YamlMetadataStore(); + _nestedObjectMetadata[propertyName] = metadata; + } + return metadata; + } + + private YamlPropertyMetadata GetOrCreatePropertyMetadata(string propertyName) { + if (!_propertyMetadata.TryGetValue(propertyName, out var metadata)) { + metadata = new YamlPropertyMetadata(); + _propertyMetadata[propertyName] = metadata; + } + return metadata; + } +} + +/// +/// Parser that preserves YAML metadata (comments, tags, styles) while parsing +/// Uses low-level Parser API with Scanner(skipComments: false) to capture comment tokens +/// +public static class YamlDocumentParser { + public static (object data, YamlMetadataStore metadata) ParseWithMetadata(string yaml, bool allowDuplicateKeys = false) { + if (string.IsNullOrEmpty(yaml)) { + return (null, null); + } + + var stringReader = new StringReader(yaml); + // Use Scanner with skipComments=false to enable comment parsing + var scanner = new Scanner(stringReader, skipComments: false); + var parser = new Parser(scanner); + var metadata = new YamlMetadataStore(); + + // Consume stream start + parser.Consume(); + + // Check for document start + if (!parser.Accept(out var _)) { + return (null, null); + } + + parser.Consume(); + + // Check if document has content + if (parser.Accept(out var _)) { + parser.Consume(); + parser.Consume(); + return (null, null); + } + + // Parse the root value + string pendingComment = null; + + // Capture the root document mapping style if it's a mapping + if (parser.Accept(out var rootMapping)) { + metadata.DocumentMappingStyle = rootMapping.Style; + } + + var data = ParseValue(parser, metadata, "", ref pendingComment, allowDuplicateKeys); + + // Consume document end and stream end + parser.Consume(); + parser.Consume(); + + return (data, metadata); + } + + private static object ParseValue(IParser parser, YamlMetadataStore metadata, string path, ref string pendingComment, bool allowDuplicateKeys) { + // Capture any comments before the value + while (parser.Accept(out var commentEvent)) { + parser.Consume(); + // Store the comment - it will be associated with the next key/value + pendingComment = commentEvent.Value; + } + + if (parser.Accept(out var _)) { + return ParseScalar(parser.Consume()); + } + else if (parser.Accept(out var _)) { + return ParseMapping(parser, metadata, path, ref pendingComment, allowDuplicateKeys); + } + else if (parser.Accept(out var _)) { + return ParseSequence(parser, metadata, path, ref pendingComment, allowDuplicateKeys); + } + else if (parser.Accept(out var _)) { + // Handle alias - for now just consume it + // TODO: Implement anchor/alias preservation in a future version + parser.Consume(); + return null; + } + + return null; + } + + private static object ParseMapping(IParser parser, YamlMetadataStore metadata, string path, ref string pendingComment, bool allowDuplicateKeys) { + parser.Consume(); + var dict = new Dictionary(); + // Track keys case-insensitively to detect duplicates + var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + // Use any pending comment from before MappingStart + string pendingBlockComment = pendingComment; + pendingComment = null; + + while (!parser.Accept(out var _)) { + // Use pending block comment from previous iteration if available + string blockComment = pendingBlockComment; + pendingBlockComment = null; + + // Capture any additional block comments before this key + while (parser.Accept(out var _)) { + var commentEvent = parser.Consume(); + if (!commentEvent.IsInline && blockComment == null) { + blockComment = commentEvent.Value; + } + } + + // Parse key + if (!parser.Accept(out var _)) { + // No more keys, break + break; + } + + var keyScalar = parser.Consume(); + var key = keyScalar.Value; + + // Check for duplicate keys (case-insensitive) + bool isDuplicate = !seenKeys.Add(key); + if (isDuplicate && !allowDuplicateKeys) { + throw new InvalidOperationException( + $"Duplicate key '{key}' found in YAML mapping at path '{path}'. " + + "YAML keys are case-insensitive and duplicates are not allowed to prevent data loss. " + + "For typed objects, use [YamlKey] attribute to map different case variations to separate properties."); + } + + // Peek at the value to get tag and style before consuming + string valueTag = null; + ScalarStyle? scalarStyle = null; + MappingStyle? mappingStyle = null; + SequenceStyle? sequenceStyle = null; + + if (parser.Accept(out var scalarPeek)) { + if (!scalarPeek.Tag.IsEmpty) { + valueTag = scalarPeek.Tag.Value; + } + scalarStyle = scalarPeek.Style; + } + else if (parser.Accept(out var mappingPeek)) { + mappingStyle = mappingPeek.Style; + if (!mappingPeek.Tag.IsEmpty) { + valueTag = mappingPeek.Tag.Value; + } + } + else if (parser.Accept(out var sequencePeek)) { + sequenceStyle = sequencePeek.Style; + if (!sequencePeek.Tag.IsEmpty) { + valueTag = sequencePeek.Tag.Value; + } + } + + // Parse value recursively + var childPath = string.IsNullOrEmpty(path) ? key : $"{path}.{key}"; + var childMetadata = metadata.GetNestedMetadata(key); + string childComment = null; + var value = ParseValue(parser, childMetadata, childPath, ref childComment, allowDuplicateKeys); + + // If child returned a pending comment and we don't have one yet, use it for next sibling + if (!string.IsNullOrEmpty(childComment) && string.IsNullOrEmpty(pendingBlockComment)) { + pendingBlockComment = childComment; + } + + // Store tag if present + if (!string.IsNullOrEmpty(valueTag)) { + metadata.SetPropertyTag(key, valueTag); + } + + // Store styles if present + if (scalarStyle.HasValue) { + metadata.SetPropertyScalarStyle(key, scalarStyle.Value); + } + if (mappingStyle.HasValue) { + metadata.SetPropertyMappingStyle(key, mappingStyle.Value); + } + if (sequenceStyle.HasValue) { + metadata.SetPropertySequenceStyle(key, sequenceStyle.Value); + } + + // Capture comments after value + // Inline comments (IsInline=true) belong to current key + // Block comments (IsInline=false) belong to next key + string inlineComment = null; + while (parser.Accept(out var _)) { + var commentEvent = parser.Consume(); + if (commentEvent.IsInline) { + inlineComment = commentEvent.Value; + } else { + // This block comment belongs to the next key + pendingBlockComment = commentEvent.Value; + } + } + + // Store comment - prefer inline comment over block comment + if (!string.IsNullOrEmpty(inlineComment)) { + metadata.SetPropertyComment(key, inlineComment.Trim()); + } else if (!string.IsNullOrEmpty(blockComment)) { + metadata.SetPropertyComment(key, blockComment.Trim()); + } + + dict[key] = value; + } + + parser.Consume(); + + // Pass any pending block comment up to parent level + pendingComment = pendingBlockComment; + + return dict; + } + + private static object ParseSequence(IParser parser, YamlMetadataStore metadata, string path, ref string pendingComment, bool allowDuplicateKeys) { + parser.Consume(); + var list = new List(); + int index = 0; + + while (!parser.Accept(out var _)) { + // Capture comment before sequence item + string itemComment = null; + while (parser.Accept(out var commentEvent)) { + parser.Consume(); + itemComment = commentEvent.Value; + } + + var childPath = $"{path}[{index}]"; + var childMetadata = metadata.GetNestedMetadata($"[{index}]"); + var value = ParseValue(parser, childMetadata, childPath, ref itemComment, allowDuplicateKeys); + + list.Add(value); + index++; + } + + parser.Consume(); + + // Note: sequences don't have pending comments to pass up + // (comments are associated with items, not with the sequence itself) + + return list; + } + + private static object ParseScalar(Scalar scalar) { + // Use existing type conversion logic + var value = scalar.Value; + var tag = scalar.Tag; + var style = scalar.Style; + + // Check for null values first (only for plain style) + if (style == ScalarStyle.Plain && (value == "" || value == "~" || value == "null" || value == "Null" || value == "NULL")) { + return null; + } + + // Handle YAML tags for explicit type conversion (tags override everything) + if (!tag.IsEmpty) { + var tagValue = tag.Value; + + switch (tagValue) { + case "tag:yaml.org,2002:int": + // Parse as BigInteger first (handles all integer sizes) + // Use Float | Integer NumberStyles to match legacy behavior + if (System.Numerics.BigInteger.TryParse(value, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var bigIntValue)) { + // Try to fit into smaller int types + if (bigIntValue >= int.MinValue && bigIntValue <= int.MaxValue) { + return (int)bigIntValue; + } + if (bigIntValue >= long.MinValue && bigIntValue <= long.MaxValue) { + return (long)bigIntValue; + } + return bigIntValue; + } else { + throw new FormatException($"Value '{value}' cannot be parsed as an integer (tag: {tagValue})"); + } + case "tag:yaml.org,2002:float": + // Parse as decimal (preferred over double for precision) + if (decimal.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var decimalValue)) { + return decimalValue; + } else { + throw new FormatException($"Value '{value}' cannot be parsed as a float (tag: {tagValue})"); + } + case "tag:yaml.org,2002:timestamp": + // Parse as DateTime + if (DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dateTimeValue)) { + return dateTimeValue; + } else { + throw new FormatException($"Value '{value}' cannot be parsed as a timestamp (tag: {tagValue})"); + } + case "tag:yaml.org,2002:bool": + if (bool.TryParse(value, out var boolValue)) { + return boolValue; + } else { + throw new FormatException($"Value '{value}' cannot be parsed as a boolean (tag: {tagValue})"); + } + case "tag:yaml.org,2002:str": + return value; + case "tag:yaml.org,2002:null": + return null; + case "!": + return value; + } + } + + // No tag - check if quoted (quoted scalars are always strings) + if (style == ScalarStyle.SingleQuoted || style == ScalarStyle.DoubleQuoted || + style == ScalarStyle.Literal || style == ScalarStyle.Folded) { + return value; + } + + // Try to parse as boolean + if (bool.TryParse(value, out var inferredBool)) { + return inferredBool; + } + + // Try to parse as integer (check for very large numbers) + // Use Float | Integer NumberStyles to match legacy behavior + if (System.Numerics.BigInteger.TryParse(value, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var inferredBigInt)) { + // Try to fit into smaller int types + if (inferredBigInt >= int.MinValue && inferredBigInt <= int.MaxValue) { + return (int)inferredBigInt; + } + if (inferredBigInt >= long.MinValue && inferredBigInt <= long.MaxValue) { + return (long)inferredBigInt; + } + return inferredBigInt; + } + + // Try to parse as decimal + if (decimal.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var inferredDecimal)) { + return inferredDecimal; + } + + // Try to parse as DateTime (ISO8601 formats) + if (DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var inferredDateTime)) { + // Only treat as DateTime if it looks like ISO8601 + if (value.Contains("T") || value.Contains("-")) { + return inferredDateTime; + } + } + + // Return as string + return value; + } +} + +/// +/// Extension methods for creating and managing PSCustomObjects with YAML metadata +/// +public static class PSObjectMetadataExtensions { + private const string MetadataPropertyName = "__psyaml_metadata"; + private const string TypeMarker = "PSYaml.EnhancedPSCustomObject"; + + /// + /// Creates an enhanced PSCustomObject from dictionary data and metadata + /// + public static PSObject CreateEnhancedPSCustomObject(IDictionary data, YamlMetadataStore metadata) { + var pso = new PSObject(); + + // Add all properties from dictionary + foreach (DictionaryEntry entry in data) { + var value = entry.Value; + var key = entry.Key.ToString(); + + // Recursively enhance nested objects + if (value is IDictionary nestedDict) { + var nestedMetadata = metadata.GetNestedMetadata(key); + value = CreateEnhancedPSCustomObject(nestedDict, nestedMetadata); + } + else if (value is IList nestedList) { + // Enhance array elements if needed + value = EnhanceList(nestedList, metadata.GetNestedMetadata(key)); + } + + pso.Properties.Add(new PSNoteProperty(key, value)); + } + + // Attach metadata store as hidden property + var metaProp = new PSNoteProperty(MetadataPropertyName, metadata); + pso.Properties.Add(metaProp); + + // Add type marker for identification + pso.TypeNames.Insert(0, TypeMarker); + + return pso; + } + + /// + /// Enhances list items by recursively converting dictionaries to enhanced PSCustomObjects + /// + private static IList EnhanceList(IList list, YamlMetadataStore metadata) { + var enhanced = new List(); + for (int i = 0; i < list.Count; i++) { + var item = list[i]; + if (item is IDictionary dict) { + enhanced.Add(CreateEnhancedPSCustomObject(dict, metadata.GetNestedMetadata($"[{i}]"))); + } else { + enhanced.Add(item); + } + } + return enhanced; + } + + /// + /// Checks if a PSObject is an enhanced PSCustomObject with YAML metadata + /// + public static bool IsEnhancedPSCustomObject(PSObject obj) { + return obj != null && obj.TypeNames.Contains(TypeMarker); + } + + /// + /// Retrieves the YAML metadata from an enhanced PSCustomObject + /// + public static YamlMetadataStore GetMetadata(PSObject obj) { + if (obj == null) { + return null; + } + var metaProp = obj.Properties[MetadataPropertyName]; + return metaProp?.Value as YamlMetadataStore; + } +} + +/// +/// Serializes enhanced PSCustomObjects with metadata (comments, styles) to YAML +/// +public static class MetadataAwareSerializer { + /// + /// Checks if a YAML tag should be emitted for the current value + /// + private static bool ShouldEmitTag(string tag, Type type) { + if (string.IsNullOrEmpty(tag)) { + return false; + } + + // The non-specific tag "!" should always be emitted to prevent type inference + if (tag == "!") { + return true; + } + + // Custom tags (starting with "!") should always be emitted for round-trip preservation + if (tag.StartsWith("!") && tag.Length > 1) { + return true; + } + + if (type == null) { + return false; + } + + // Map standard YAML tags to .NET types - only emit if they match + return tag switch + { + "tag:yaml.org,2002:str" => type == typeof(string), + "tag:yaml.org,2002:int" => type == typeof(int) || type == typeof(long) || type == typeof(BigInteger), + "tag:yaml.org,2002:float" => type == typeof(float) || type == typeof(double) || type == typeof(decimal), + "tag:yaml.org,2002:bool" => type == typeof(bool), + "tag:yaml.org,2002:null" => type == null, + "tag:yaml.org,2002:timestamp" => type == typeof(DateTime), + _ => false,// Unknown standard tag - don't emit + }; + } + + private static string GetTagFromType(object value) { + return value switch + { + int => "tag:yaml.org,2002:int", + long => "tag:yaml.org,2002:int", + System.Numerics.BigInteger => "tag:yaml.org,2002:int", + float => "tag:yaml.org,2002:float", + double => "tag:yaml.org,2002:float", + decimal => "tag:yaml.org,2002:float", + bool => "tag:yaml.org,2002:bool", + string => "tag:yaml.org,2002:str", + DateTime => "tag:yaml.org,2002:timestamp", + _ => string.Empty + }; + } + + public static string Serialize(PSObject obj, bool indentedSequences = false, bool emitTags = false) { + var metadata = PSObjectMetadataExtensions.GetMetadata(obj); + if (metadata == null) { + throw new InvalidOperationException("Object does not have YAML metadata"); + } + + var stringWriter = new StringWriter(); + + // Create emitter with or without indented sequences + IEmitter emitter; + if (indentedSequences) { + // Use EmitterSettings to enable indented sequences + var emitterSettings = new EmitterSettings( + bestIndent: 2, + bestWidth: int.MaxValue, + isCanonical: false, + maxSimpleKeyLength: 1024, + skipAnchorName: false, + indentSequences: true + ); + emitter = new Emitter(stringWriter, emitterSettings); + } else { + emitter = new Emitter(stringWriter); + } + + emitter.Emit(new StreamStart()); + emitter.Emit(new DocumentStart()); + + SerializePSObject(obj, metadata, emitter, MappingStyle.Block, emitTags); + + emitter.Emit(new DocumentEnd(true)); + emitter.Emit(new StreamEnd()); + + return stringWriter.ToString(); + } + + private static void SerializePSObject(PSObject obj, YamlMetadataStore metadata, IEmitter emitter, MappingStyle style = MappingStyle.Block, bool emitTags = false) { + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, style)); + + foreach (var prop in obj.Properties) { + // Skip internal metadata property + if (prop.Name == "__psyaml_metadata") continue; + + // Emit comment if present + var comment = metadata.GetPropertyComment(prop.Name); + if (!string.IsNullOrEmpty(comment)) { + emitter.Emit(new Comment(comment, false)); + } + + // Emit key + emitter.Emit(new Scalar(prop.Name)); + + // Get stored tag and scalar style for this property + var storedTag = metadata.GetPropertyTag(prop.Name); + var scalarStyle = metadata.GetPropertyScalarStyle(prop.Name) ?? ScalarStyle.Any; + + // Emit value + if (prop.Value == null) { + emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); + } + else if (prop.Value is PSObject nestedPSObj && PSObjectMetadataExtensions.IsEnhancedPSCustomObject(nestedPSObj)) { + // Recursively serialize nested enhanced objects + var nestedMetadata = metadata.GetNestedMetadata(prop.Name); + var nestedMappingStyle = metadata.GetPropertyMappingStyle(prop.Name) ?? MappingStyle.Block; + SerializePSObject(nestedPSObj, nestedMetadata, emitter, nestedMappingStyle, emitTags); + } + else if (prop.Value is IList list) { + var sequenceStyle = metadata.GetPropertySequenceStyle(prop.Name) ?? SequenceStyle.Block; + SerializeList(list, metadata.GetNestedMetadata(prop.Name), emitter, sequenceStyle, emitTags); + } + else if (prop.Value is bool boolValue) { + // If emitTags is enabled and no stored tag, infer tag from type + var tagToUse = storedTag; + if (emitTags && string.IsNullOrEmpty(storedTag)) { + tagToUse = GetTagFromType(prop.Value); + } + var shouldEmitTag = !string.IsNullOrEmpty(tagToUse) && (emitTags || ShouldEmitTag(tagToUse, typeof(bool))); + var tag = shouldEmitTag ? tagToUse : TagName.Empty; + var isImplicit = !shouldEmitTag; // Implicit if no tag is being emitted + emitter.Emit(new Scalar(AnchorName.Empty, tag, boolValue ? "true" : "false", scalarStyle, isImplicit, false)); + } + else if (prop.Value is System.Numerics.BigInteger bigInt) { + // If emitTags is enabled and no stored tag, infer tag from type + var tagToUse = storedTag; + if (emitTags && string.IsNullOrEmpty(storedTag)) { + tagToUse = GetTagFromType(prop.Value); + } + var shouldEmitTag = !string.IsNullOrEmpty(tagToUse) && (emitTags || ShouldEmitTag(tagToUse, typeof(BigInteger))); + var tag = shouldEmitTag ? tagToUse : TagName.Empty; + var isImplicit = !shouldEmitTag; + emitter.Emit(new Scalar(AnchorName.Empty, tag, bigInt.ToString(), scalarStyle, isImplicit, false)); + } + else if (prop.Value is DateTime dateTime) { + // If emitTags is enabled and no stored tag, infer tag from type + var tagToUse = storedTag; + if (emitTags && string.IsNullOrEmpty(storedTag)) { + tagToUse = GetTagFromType(prop.Value); + } + var shouldEmitTag = !string.IsNullOrEmpty(tagToUse) && (emitTags || ShouldEmitTag(tagToUse, typeof(DateTime))); + var tag = shouldEmitTag ? tagToUse : TagName.Empty; + var isImplicit = !shouldEmitTag; + emitter.Emit(new Scalar(AnchorName.Empty, tag, dateTime.ToString("o"), scalarStyle, isImplicit, false)); + } + else { + // If emitTags is enabled and no stored tag, infer tag from type + var tagToUse = storedTag; + if (emitTags && string.IsNullOrEmpty(storedTag)) { + tagToUse = GetTagFromType(prop.Value); + } + var valueType = prop.Value.GetType(); + var shouldEmitTag = !string.IsNullOrEmpty(tagToUse) && (emitTags || ShouldEmitTag(tagToUse, valueType)); + var tag = shouldEmitTag ? tagToUse : TagName.Empty; + var isImplicit = !shouldEmitTag; // Implicit if no tag is being emitted + emitter.Emit(new Scalar(AnchorName.Empty, tag, prop.Value.ToString(), scalarStyle, isImplicit, false)); + } + } + + emitter.Emit(new MappingEnd()); + } + + private static void SerializeList(IList list, YamlMetadataStore metadata, IEmitter emitter, SequenceStyle style = SequenceStyle.Block, bool emitTags = false) { + emitter.Emit(new SequenceStart(AnchorName.Empty, TagName.Empty, true, style)); + + for (int i = 0; i < list.Count; i++) { + var item = list[i]; + + if (item == null) { + emitter.Emit(new Scalar("null")); + } + else if (item is PSObject nestedPSObj && PSObjectMetadataExtensions.IsEnhancedPSCustomObject(nestedPSObj)) { + var itemMetadata = metadata.GetNestedMetadata($"[{i}]"); + SerializePSObject(nestedPSObj, itemMetadata, emitter, MappingStyle.Block, emitTags); + } + else if (item is bool boolValue) { + // If emitTags is enabled, infer tag from type + if (emitTags) { + var tag = GetTagFromType(item); + emitter.Emit(new Scalar(AnchorName.Empty, tag, boolValue ? "true" : "false", ScalarStyle.Any, false, false)); + } else { + emitter.Emit(new Scalar(boolValue ? "true" : "false")); + } + } + else if (item is System.Numerics.BigInteger bigInt) { + // If emitTags is enabled, infer tag from type + if (emitTags) { + var tag = GetTagFromType(item); + emitter.Emit(new Scalar(AnchorName.Empty, tag, bigInt.ToString(), ScalarStyle.Plain, false, false)); + } else { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, bigInt.ToString(), ScalarStyle.Plain, true, false)); + } + } + else if (item is DateTime dateTime) { + // If emitTags is enabled, infer tag from type + if (emitTags) { + var tag = GetTagFromType(item); + emitter.Emit(new Scalar(AnchorName.Empty, tag, dateTime.ToString("o"), ScalarStyle.Any, false, false)); + } else { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, dateTime.ToString("o"), ScalarStyle.Any, true, false)); + } + } + else { + // If emitTags is enabled, infer tag from type + if (emitTags) { + var tag = GetTagFromType(item); + if (!string.IsNullOrEmpty(tag)) { + emitter.Emit(new Scalar(AnchorName.Empty, tag, item.ToString(), ScalarStyle.Any, false, false)); + } else { + emitter.Emit(new Scalar(item.ToString())); + } + } else { + emitter.Emit(new Scalar(item.ToString())); + } + } + } + + emitter.Emit(new SequenceEnd()); + } +} diff --git a/src/PowerShellYaml.Module/PowerShellYaml.Module.csproj b/src/PowerShellYaml.Module/PowerShellYaml.Module.csproj new file mode 100644 index 0000000..74d5b27 --- /dev/null +++ b/src/PowerShellYaml.Module/PowerShellYaml.Module.csproj @@ -0,0 +1,24 @@ + + + + netstandard2.0 + PowerShellYaml.Module + latest + enable + + + + + + + + + + + + + + + + + diff --git a/src/PowerShellYaml.Module/TypedYamlConverter.cs b/src/PowerShellYaml.Module/TypedYamlConverter.cs new file mode 100644 index 0000000..0ba9eb9 --- /dev/null +++ b/src/PowerShellYaml.Module/TypedYamlConverter.cs @@ -0,0 +1,642 @@ +// Copyright 2016-2026 Cloudbase Solutions Srl +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace PowerShellYaml.Module; + +/// +/// Public API for typed YAML conversion. +/// Provides static methods for serializing and deserializing YamlBase objects. +/// +public static class TypedYamlConverter +{ + #region Deserialization (YAML to Object) + + /// + /// Deserialize YAML string to a typed object inheriting from YamlBase. + /// + public static object? FromYaml(string yaml, Type targetType) + { + // Validate that the type inherits from YamlBase + if (targetType is null || !typeof(YamlBase).IsAssignableFrom(targetType)) + { + throw new ArgumentException($"Type must inherit from PowerShellYaml.YamlBase", nameof(targetType)); + } + + // Parse YAML with metadata preservation (allow duplicate keys for typed mode) + var (data, metadata) = YamlDocumentParser.ParseWithMetadata(yaml, allowDuplicateKeys: true); + + // Convert to Dictionary for FromDictionary + var dict = ConvertToStringKeyDictionary(data); + + // Validate that case-insensitive duplicate YAML keys have explicit mappings in the type + ValidateDuplicateKeysHaveExplicitMappings(dict, targetType); + + // Create an instance of the user's PowerShell class + var instance = (YamlBase?)Activator.CreateInstance(targetType); + if (instance is null) + { + throw new InvalidOperationException($"Failed to create instance of type {targetType.FullName}"); + } + + // Copy top-level metadata BEFORE calling FromDictionary + // This ensures custom converters can access tags during deserialization + if (metadata != null) + { + CopyTopLevelMetadataToYamlBase(instance, metadata); + + // Copy document-level mapping style if present + if (metadata.DocumentMappingStyle.HasValue) + { + instance.SetDocumentMappingStyle(metadata.DocumentMappingStyle.Value.ToString()); + } + } + + // Populate the instance using the abstract method + instance.FromDictionary(dict); + + // Copy nested object metadata AFTER FromDictionary has created the nested objects + if (metadata != null) + { + CopyNestedMetadataToYamlBase(instance, metadata, ""); + } + + return instance; + } + + public static Dictionary ConvertToStringKeyDictionary(object? obj) + { + if (obj is null) + return new Dictionary(); + + if (obj is IDictionary dict) + { + var result = new Dictionary(); + foreach (DictionaryEntry entry in dict) + { + var key = entry.Key?.ToString() ?? string.Empty; + var value = ConvertValue(entry.Value); + result[key] = value; + } + return result; + } + + throw new InvalidOperationException("YAML root must be a mapping"); + } + + private static object? ConvertValue(object? value) + { + if (value is null) + return null; + + // Recursively convert nested dictionaries + if (value is IDictionary dict) + { + var result = new Dictionary(); + foreach (DictionaryEntry entry in dict) + { + var key = entry.Key?.ToString() ?? string.Empty; + result[key] = ConvertValue(entry.Value); + } + return result; + } + + // Convert lists + if (value is IList list) + { + var result = new List(); + foreach (var item in list) + { + result.Add(ConvertValue(item)); + } + return result; + } + + return value; + } + + /// + /// Copy top-level property metadata (tags, comments, styles) to YamlBase instance. + /// This must be called BEFORE FromDictionary so custom converters can access tags. + /// + public static void CopyTopLevelMetadataToYamlBase(YamlBase instance, YamlMetadataStore metadataStore) + { + var properties = instance.GetType().GetProperties(); + + foreach (var prop in properties) + { + var propName = prop.Name; + var yamlKey = GetYamlKeyForProperty(prop); + + // Copy all metadata for this property + var comment = metadataStore.GetPropertyComment(yamlKey); + if (!string.IsNullOrEmpty(comment)) + { + instance.SetPropertyComment(propName, comment); + } + + var scalarStyle = metadataStore.GetPropertyScalarStyle(yamlKey); + if (scalarStyle.HasValue) + { + instance.SetPropertyScalarStyle(propName, scalarStyle.Value.ToString()); + } + + var mappingStyle = metadataStore.GetPropertyMappingStyle(yamlKey); + if (mappingStyle.HasValue) + { + instance.SetPropertyMappingStyle(propName, mappingStyle.Value.ToString()); + } + + var sequenceStyle = metadataStore.GetPropertySequenceStyle(yamlKey); + if (sequenceStyle.HasValue) + { + instance.SetPropertySequenceStyle(propName, sequenceStyle.Value.ToString()); + } + + var tag = metadataStore.GetPropertyTag(yamlKey); + if (!string.IsNullOrEmpty(tag)) + { + instance.SetPropertyTag(propName, tag); + } + } + } + + /// + /// Copy nested object metadata to YamlBase instance. + /// This must be called AFTER FromDictionary so nested objects exist. + /// + public static void CopyNestedMetadataToYamlBase(YamlBase instance, YamlMetadataStore metadataStore, string pathPrefix) + { + var properties = instance.GetType().GetProperties(); + + foreach (var prop in properties) + { + var yamlKey = GetYamlKeyForProperty(prop); + var propValue = prop.GetValue(instance); + + // Handle nested YamlBase objects + if (propValue is YamlBase nestedYamlBase) + { + var nestedMetadata = metadataStore.GetNestedMetadata(yamlKey); + if (nestedMetadata != null) + { + // Copy top-level metadata for the nested object first + CopyTopLevelMetadataToYamlBase(nestedYamlBase, nestedMetadata); + // Then recursively copy its nested metadata + CopyNestedMetadataToYamlBase(nestedYamlBase, nestedMetadata, $"{pathPrefix}{yamlKey}."); + } + } + // Handle arrays of YamlBase objects + else if (propValue is Array array) + { + var arrayPropertyMetadata = metadataStore.GetNestedMetadata(yamlKey); + if (arrayPropertyMetadata != null) + { + for (int i = 0; i < array.Length; i++) + { + if (array.GetValue(i) is YamlBase arrayItem) + { + var itemMetadata = arrayPropertyMetadata.GetNestedMetadata($"[{i}]"); + if (itemMetadata != null) + { + CopyTopLevelMetadataToYamlBase(arrayItem, itemMetadata); + CopyNestedMetadataToYamlBase(arrayItem, itemMetadata, $"{pathPrefix}{yamlKey}[{i}]."); + } + } + } + } + } + } + } + + /// + /// Validate that case-insensitive duplicate YAML keys have explicit [YamlKey] mappings. + /// This prevents silent data loss when YAML contains keys like "test" and "Test". + /// + internal static void ValidateDuplicateKeysHaveExplicitMappings(Dictionary dict, Type targetType) + { + // First, check if the YAML dictionary has case-insensitive duplicate keys + var yamlKeysGrouped = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var yamlKey in dict.Keys) + { + if (!yamlKeysGrouped.ContainsKey(yamlKey)) + { + yamlKeysGrouped[yamlKey] = new List(); + } + yamlKeysGrouped[yamlKey].Add(yamlKey); + } + + // Find groups with multiple case variations + foreach (var kvp in yamlKeysGrouped) + { + if (kvp.Value.Count > 1) + { + // We have case-insensitive duplicates (e.g., "test" and "Test") + // Now verify that ALL of these variations are explicitly mapped using [YamlKey] + var properties = targetType.GetProperties(); + var explicitlyMapped = new HashSet(); + + foreach (var prop in properties) + { + var yamlKeyAttr = prop.GetCustomAttribute(); + if (yamlKeyAttr != null) + { + // This property has an explicit YamlKey attribute + explicitlyMapped.Add(yamlKeyAttr.Key); + } + } + + // Check if all case variations are explicitly mapped + var unmappedKeys = new List(); + foreach (var yamlKey in kvp.Value) + { + if (!explicitlyMapped.Contains(yamlKey)) + { + unmappedKeys.Add(yamlKey); + } + } + + if (unmappedKeys.Count > 0) + { + throw new InvalidOperationException( + $"YAML contains case-insensitive duplicate keys: {string.Join(", ", kvp.Value)}. " + + $"To prevent data loss, all variations must be explicitly mapped using [YamlKey] attributes. " + + $"Unmapped keys: {string.Join(", ", unmappedKeys)}"); + } + } + } + } + + #endregion + + #region Serialization (Object to YAML) + + /// + /// Serialize a YamlBase object to YAML string. + /// + public static string ToYaml(YamlBase obj, bool omitNull = false, bool emitTags = false, bool useFlowStyle = false, bool useBlockStyle = false, bool useSequenceFlowStyle = false, bool useSequenceBlockStyle = false, bool indentedSequences = false) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + // Convert boolean flags to nullable style overrides + MappingStyle? mappingStyleOverride = null; + if (useBlockStyle) + { + mappingStyleOverride = MappingStyle.Block; + } + else if (useFlowStyle) + { + mappingStyleOverride = MappingStyle.Flow; + } + + SequenceStyle? sequenceStyleOverride = null; + if (useSequenceBlockStyle) + { + sequenceStyleOverride = SequenceStyle.Block; + } + else if (useSequenceFlowStyle) + { + sequenceStyleOverride = SequenceStyle.Flow; + } + + return SerializeWithMetadata(obj, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, indentedSequences); + } + + public static string SerializeWithMetadata(YamlBase obj, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride, bool indentedSequences = false) + { + var stringWriter = new StringWriter(); + + // Create emitter with or without indented sequences + IEmitter emitter; + if (indentedSequences) + { + // EmitterSettings constructor signature (from YamlDotNet): + // EmitterSettings(int bestIndent, int bestWidth, bool isCanonical, int maxSimpleKeyLength, bool skipAnchorName, bool indentSequences, string? newLine, bool forceIndentLess) + // Use default values for most parameters, only set indentSequences to true + var emitterSettings = new EmitterSettings( + bestIndent: 2, + bestWidth: int.MaxValue, + isCanonical: false, + maxSimpleKeyLength: 1024, + skipAnchorName: false, + indentSequences: true + ); + emitter = new Emitter(stringWriter, emitterSettings); + } + else + { + emitter = new Emitter(stringWriter); + } + + emitter.Emit(new StreamStart()); + emitter.Emit(new DocumentStart()); + + SerializeObject(obj, emitter, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride); + + emitter.Emit(new DocumentEnd(true)); // true = implicit (no "..." marker) + emitter.Emit(new StreamEnd()); + + return stringWriter.ToString(); + } + + private static void SerializeObject(YamlBase obj, IEmitter emitter, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride) + { + // Determine mapping style with precedence: + // 1. mappingStyleOverride (if set by user) + // 2. Object metadata (document-level mapping style) + // 3. Default to block + MappingStyle mappingStyle; + if (mappingStyleOverride.HasValue) + { + mappingStyle = mappingStyleOverride.Value; + } + else + { + // Check if the object has a document-level mapping style + var documentStyleStr = obj.GetDocumentMappingStyle(); + mappingStyle = documentStyleStr == "Flow" ? MappingStyle.Flow : MappingStyle.Block; + } + SerializeObjectWithStyle(obj, emitter, emitTags, omitNull, mappingStyle, mappingStyleOverride, sequenceStyleOverride); + } + + private static void SerializeObjectWithStyle(YamlBase obj, IEmitter emitter, bool emitTags, bool omitNull, MappingStyle mappingStyle, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride) + { + var dict = obj.ToDictionary(); + var metadata = obj.GetAllMetadata(); + var type = obj.GetType(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + emitter.Emit(new MappingStart(null, null, false, mappingStyle)); + + // Iterate through properties to preserve order and get correct YAML keys + foreach (var prop in properties) + { + if (prop.Name == "EqualityContract") continue; + + var propName = prop.Name; + var yamlKey = GetYamlKeyForProperty(prop); + + // Get value from dictionary + if (!dict.TryGetValue(yamlKey, out var value)) + { + continue; + } + + // Skip null values if OmitNull is enabled + if (omitNull && value is null) + { + continue; + } + + // Emit comment if present + if (metadata.TryGetValue(propName, out var propMetadata)) + { + if (propMetadata.TryGetValue("comment", out var commentObj) && commentObj is string comment && !string.IsNullOrEmpty(comment)) + { + emitter.Emit(new Comment(comment, false)); + } + } + + // Emit key + emitter.Emit(new Scalar(yamlKey)); + + // Emit value with metadata + EmitValue(value, emitter, obj, propName, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride); + } + + emitter.Emit(new MappingEnd()); + } + + private static void EmitValue(object? value, IEmitter emitter, YamlBase? parentObj, string? propertyName, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride) + { + if (value is null) + { + var nullTag = emitTags ? new TagName("tag:yaml.org,2002:null") : TagName.Empty; + emitter.Emit(new Scalar(AnchorName.Empty, nullTag, "null", ScalarStyle.Plain, !emitTags, false)); + return; + } + + // Handle nested YamlBase objects + if (value is YamlBase nestedYaml) + { + // Determine mapping style with precedence: + // 1. mappingStyleOverride (if set by user) + // 2. Parent's property metadata (property-level mapping style) + // 3. Default to block + MappingStyle mappingStyle; + if (mappingStyleOverride.HasValue) + { + mappingStyle = mappingStyleOverride.Value; + } + else if (parentObj != null && propertyName != null) + { + var styleStr = parentObj.GetPropertyMappingStyle(propertyName); + mappingStyle = styleStr == "Flow" ? MappingStyle.Flow : MappingStyle.Block; + } + else + { + mappingStyle = MappingStyle.Block; + } + + SerializeObjectWithStyle(nestedYaml, emitter, emitTags, omitNull, mappingStyle, mappingStyleOverride, sequenceStyleOverride); + return; + } + + // Handle dictionaries (from nested objects) + if (value is Dictionary dict) + { + // Determine mapping style with same precedence as nested YamlBase + MappingStyle mappingStyle; + if (mappingStyleOverride.HasValue) + { + mappingStyle = mappingStyleOverride.Value; + } + else if (parentObj != null && propertyName != null) + { + var styleStr = parentObj.GetPropertyMappingStyle(propertyName); + mappingStyle = styleStr == "Flow" ? MappingStyle.Flow : MappingStyle.Block; + } + else + { + mappingStyle = MappingStyle.Block; + } + + emitter.Emit(new MappingStart(null, null, false, mappingStyle)); + foreach (var kvp in dict) + { + // Skip null values in dictionaries if OmitNull is enabled + if (omitNull && kvp.Value is null) + { + continue; + } + + emitter.Emit(new Scalar(kvp.Key)); + EmitValue(kvp.Value, emitter, null, null, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride); + } + emitter.Emit(new MappingEnd()); + return; + } + + // Handle lists/arrays + if (value is IList list) + { + // Determine sequence style with precedence: + // 1. sequenceStyleOverride (if set by user) + // 2. Parent's property metadata (property-level sequence style) + // 3. Default to block + SequenceStyle seqStyle; + if (sequenceStyleOverride.HasValue) + { + seqStyle = sequenceStyleOverride.Value; + } + else if (parentObj != null && propertyName != null) + { + var styleStr = parentObj.GetPropertySequenceStyle(propertyName); + seqStyle = styleStr == "Flow" ? SequenceStyle.Flow : SequenceStyle.Block; + } + else + { + seqStyle = SequenceStyle.Block; + } + + emitter.Emit(new SequenceStart(null, null, false, seqStyle)); + foreach (var item in list) + { + // Note: We don't skip null items in arrays - preserve array structure + EmitValue(item, emitter, null, null, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride); + } + emitter.Emit(new SequenceEnd()); + return; + } + + // Emit scalar value with style and tag from metadata + var scalarStyle = ScalarStyle.Any; + var tag = TagName.Empty; + + if (parentObj != null && propertyName != null) + { + // Get scalar style from metadata + var styleStr = parentObj.GetPropertyScalarStyle(propertyName); + if (!string.IsNullOrEmpty(styleStr)) + { + scalarStyle = styleStr switch + { + "DoubleQuoted" => ScalarStyle.DoubleQuoted, + "SingleQuoted" => ScalarStyle.SingleQuoted, + "Literal" => ScalarStyle.Literal, + "Folded" => ScalarStyle.Folded, + "Plain" => ScalarStyle.Plain, + _ => ScalarStyle.Any + }; + } + + // Get tag from metadata (e.g., tag:yaml.org,2002:int) + var tagStr = parentObj.GetPropertyTag(propertyName); + if (!string.IsNullOrEmpty(tagStr)) + { + tag = new TagName(tagStr!); // tagStr is guaranteed non-null by the check above + } + } + + // If emitTags is enabled and no tag from metadata, infer tag from .NET type + if (emitTags && tag.IsEmpty) + { + tag = GetTagFromType(value); + } + + var valueStr = value.ToString() ?? string.Empty; + + // If we have an explicit tag from metadata or emitTags, force it to be emitted + // by setting both implicit flags to false + var isPlainImplicit = tag.IsEmpty; + var isQuotedImplicit = tag.IsEmpty; + + emitter.Emit(new Scalar(AnchorName.Empty, tag, valueStr, scalarStyle, isPlainImplicit, isQuotedImplicit)); + } + + private static TagName GetTagFromType(object value) + { + return value switch + { + int => new TagName("tag:yaml.org,2002:int"), + long => new TagName("tag:yaml.org,2002:int"), + System.Numerics.BigInteger => new TagName("tag:yaml.org,2002:int"), + float => new TagName("tag:yaml.org,2002:float"), + double => new TagName("tag:yaml.org,2002:float"), + decimal => new TagName("tag:yaml.org,2002:float"), + bool => new TagName("tag:yaml.org,2002:bool"), + string => new TagName("tag:yaml.org,2002:str"), + DateTime => new TagName("tag:yaml.org,2002:timestamp"), + _ => TagName.Empty + }; + } + + #endregion + + #region Helper Methods + + /// + /// Get the YAML key for a property, checking for YamlKeyAttribute first. + /// + private static string GetYamlKeyForProperty(PropertyInfo prop) + { + // Check if property has YamlKeyAttribute + var yamlKeyAttr = prop.GetCustomAttribute(); + if (yamlKeyAttr != null) + { + return yamlKeyAttr.Key; + } + + // Fall back to automatic conversion + return ConvertPropertyNameToYamlKey(prop.Name); + } + + private static string ConvertPropertyNameToYamlKey(string propertyName) + { + // Convert PascalCase to hyphenated-case + // Example: AppName -> app-name, DatabaseHost -> database-host + var result = new System.Text.StringBuilder(); + for (int i = 0; i < propertyName.Length; i++) + { + char c = propertyName[i]; + if (char.IsUpper(c)) + { + if (i > 0) + { + result.Append('-'); + } + result.Append(char.ToLower(c)); + } + else + { + result.Append(c); + } + } + return result.ToString(); + } + + #endregion +} diff --git a/src/PowerShellYaml/PowerShellYaml.csproj b/src/PowerShellYaml/PowerShellYaml.csproj new file mode 100644 index 0000000..7ce0a56 --- /dev/null +++ b/src/PowerShellYaml/PowerShellYaml.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.0 + PowerShellYaml + latest + enable + + + + diff --git a/src/PowerShellYaml/YamlBase.cs b/src/PowerShellYaml/YamlBase.cs new file mode 100644 index 0000000..291b9d4 --- /dev/null +++ b/src/PowerShellYaml/YamlBase.cs @@ -0,0 +1,678 @@ +// Copyright 2016-2026 Cloudbase Solutions Srl +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace PowerShellYaml; + +/// +/// Interface for custom YAML type converters. +/// Implement this interface to provide custom serialization/deserialization for specific YAML tags and types. +/// PowerShell users can create classes with ConvertFromYaml and ConvertToYaml methods that will be called via reflection. +/// +public interface IYamlTypeConverter +{ + /// + /// Determines whether this converter can handle the given tag and target type. + /// + /// The YAML tag (e.g., "!timestamp", "tag:yaml.org,2002:int"). Null if no tag present. + /// The .NET type the value should be converted to. + /// True if this converter can handle the conversion, false otherwise. + bool CanHandle(string? tag, Type targetType); + + /// + /// Deserialize a YAML value to the target type. + /// + /// The raw value from YAML (string, Dictionary, List, etc.) + /// The YAML tag if present, null otherwise. + /// The .NET type to convert to. + /// The deserialized object. + object? Unmarshal(object? data, string? tag, Type targetType); + + /// + /// Serialize an object to YAML representation. + /// + /// The object to serialize. + /// Output parameter: the YAML tag to use (e.g., "!timestamp"). + /// The serialized representation (string, Dictionary, List, etc.). + object? Marshal(object? value, out string? tag); +} + +/// +/// Base class for PowerShell-based custom type converters. +/// Inherit from this class in PowerShell and override the abstract methods to create custom converters. +/// +/// +/// class SemVerConverter : YamlConverter { +/// [bool] CanHandle([string]$tag, [Type]$targetType) { +/// return ($tag -eq '!semver' -or $tag -eq $null) -and $targetType -eq [SemanticVersion] +/// } +/// +/// [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { +/// # Parse $data and return object +/// return [SemanticVersion]::new() +/// } +/// +/// [hashtable] ConvertToYaml([object]$value) { +/// return @{ Value = $value.ToString(); Tag = '!semver' } +/// } +/// } +/// +public abstract class YamlConverter : IYamlTypeConverter +{ + /// + /// Determines whether this converter can handle the given tag and target type. + /// Override this method to implement custom tag/type checking logic. + /// Default implementation returns true for all inputs. + /// + public virtual bool CanHandle(string? tag, Type targetType) + { + return true; + } + + /// + /// Deserialize a YAML value to the target type. + /// This is an abstract method that must be overridden in PowerShell. + /// + public abstract object? ConvertFromYaml(object? data, string? tag, Type targetType); + + /// + /// Serialize an object to YAML representation. + /// This is an abstract method that must be overridden in PowerShell. + /// Return a hashtable with 'Value' and 'Tag' keys, or just the value directly. + /// + public abstract object? ConvertToYaml(object? value); + + // IYamlTypeConverter interface implementation + object? IYamlTypeConverter.Unmarshal(object? data, string? tag, Type targetType) + { + return ConvertFromYaml(data, tag, targetType); + } + + object? IYamlTypeConverter.Marshal(object? value, out string? tag) + { + var result = ConvertToYaml(value); + + // Handle Dictionary + if (result is Dictionary dict) + { + tag = dict.TryGetValue("Tag", out var tagValue) ? tagValue as string : null; + return dict.TryGetValue("Value", out var valueResult) ? valueResult : null; + } + + // Handle PowerShell Hashtable + if (result is IDictionary hashtable) + { + tag = hashtable.Contains("Tag") ? hashtable["Tag"] as string : null; + return hashtable.Contains("Value") ? hashtable["Value"] : null; + } + + // Fallback: treat result as the value, no tag + tag = null; + return result; + } +} + +/// +/// Attribute to specify a custom YAML type converter for a property. +/// Use this to control how a property is serialized/deserialized to/from YAML. +/// +/// +/// class TimestampConverter : IYamlTypeConverter { +/// // Implementation... +/// } +/// +/// class MyClass : YamlBase { +/// [YamlConverter(typeof(TimestampConverter))] +/// [DateTime]$CreatedAt +/// } +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class YamlConverterAttribute : Attribute +{ + public Type? ConverterType { get; } + public string? ConverterTypeName { get; } + + // Constructor for C# usage with Type + public YamlConverterAttribute(Type converterType) + { + if (!typeof(IYamlTypeConverter).IsAssignableFrom(converterType)) + { + throw new ArgumentException( + $"Type '{converterType.FullName}' must implement IYamlTypeConverter interface.", + nameof(converterType)); + } + ConverterType = converterType; + } + + // Constructor for PowerShell usage with type name string (avoids ALC issues) + public YamlConverterAttribute(string converterTypeName) + { + ConverterTypeName = converterTypeName; + } +} + +/// +/// Attribute to specify the YAML key name for a property. +/// Use this when you need to map a YAML key to a property with a different name. +/// This is especially useful for case-sensitive YAML keys since PowerShell class properties are case-insensitive. +/// +/// +/// class MyClass : YamlBase { +/// [YamlKey("Test")] +/// [string]$CapitalizedTest +/// +/// [YamlKey("test")] +/// [int]$LowercaseTest +/// } +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class YamlKeyAttribute(string key) : Attribute +{ + public string Key { get; } = key; +} + +/// +/// Base class for typed YAML objects that users can inherit from in PowerShell. +/// This class is loaded into the Default ALC, making it available for PowerShell class inheritance. +/// Includes metadata storage for YAML comments, styles, tags, etc. +/// +public abstract class YamlBase +{ + // Metadata storage using only ALC-safe types (Dictionary from mscorlib) + // Stores: property name -> metadata dictionary (e.g., "comment" -> "comment text") + private readonly Dictionary> _metadata = + new Dictionary>(); + + /// + /// Convert this object to a dictionary for YAML serialization. + /// Override this method to provide custom serialization logic. + /// Default implementation uses reflection to serialize all public properties. + /// + public virtual Dictionary ToDictionary() + { + var dict = new Dictionary(); + var type = GetType(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + // Skip metadata property + if (prop.Name == "_metadata" || prop.Name == "EqualityContract") + continue; + + var value = prop.GetValue(this); + var key = GetYamlKeyForProperty(prop); + + // Check for custom converter attribute using GetCustomAttributesData to avoid ALC issues + var converterType = GetConverterTypeFromProperty(prop); + if (converterType != null && value != null) + { + try + { + var converter = (IYamlTypeConverter?)Activator.CreateInstance(converterType); + if (converter == null) + { + throw new InvalidOperationException( + $"Failed to create instance of converter type '{converterType.FullName}'"); + } + + // Use custom converter to marshal the value + string? tag; + var marshaledValue = converter.Marshal(value, out tag); + + // Store the tag in metadata if provided + if (!string.IsNullOrEmpty(tag)) + { + SetPropertyTag(prop.Name, tag); + } + + dict[key] = marshaledValue; + continue; + } + catch (Exception ex) when (!(ex is InvalidOperationException)) + { + throw new InvalidOperationException( + $"Custom converter '{converterType.Name}' failed to serialize property '{prop.Name}' " + + $"with value '{value}'. See inner exception for details.", ex); + } + } + + // Keep YamlBase references for metadata preservation + // Arrays are kept as-is (they'll be handled by serializer) + dict[key] = value; + } + + return dict; + } + + /// + /// Populate this object from a dictionary after YAML deserialization. + /// Override this method to provide custom deserialization logic. + /// Default implementation uses reflection to populate all public properties. + /// + public virtual void FromDictionary(Dictionary data) + { + var type = GetType(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + // Skip metadata property + if (prop.Name == "_metadata" || prop.Name == "EqualityContract") + continue; + + if (!prop.CanWrite) + continue; + + var yamlKey = GetYamlKeyForProperty(prop); + if (!data.ContainsKey(yamlKey)) + continue; + + var value = data[yamlKey]; + if (value == null) + { + prop.SetValue(this, null); + continue; + } + + // Check for custom converter attribute using GetCustomAttributesData to avoid ALC issues + var converterType = GetConverterTypeFromProperty(prop); + if (converterType != null) + { + try + { + var converter = (IYamlTypeConverter?)Activator.CreateInstance(converterType); + if (converter == null) + { + throw new InvalidOperationException( + $"Failed to create instance of converter type '{converterType.FullName}'"); + } + + // Get the tag from metadata if available + string? tag = null; + if (_metadata.TryGetValue(prop.Name, out var propMeta)) + { + if (propMeta.TryGetValue("tag", out var tagValue)) + { + tag = tagValue as string; + } + } + + // Check if converter can handle this + if (!converter.CanHandle(tag, prop.PropertyType)) + { + throw new InvalidOperationException( + $"Custom converter '{converterType.Name}' registered for property '{prop.Name}' " + + $"cannot handle tag '{tag ?? "(none)"}' with target type '{prop.PropertyType.Name}'. " + + $"The converter's CanHandle() method returned false."); + } + + // Use custom converter + var convertedValue = converter.Unmarshal(value, tag, prop.PropertyType); + prop.SetValue(this, convertedValue); + continue; + } + catch (Exception ex) when (ex is not InvalidOperationException) + { + throw new InvalidOperationException( + $"Custom converter '{converterType.Name}' failed to deserialize property '{prop.Name}' " + + $"with value '{value}'. See inner exception for details.", ex); + } + } + + // Check if value is a nested object (dictionary) + if (value is Dictionary nestedDict) + { + // Handle nested YamlBase objects + if (typeof(YamlBase).IsAssignableFrom(prop.PropertyType)) + { + var nestedInstance = (YamlBase?)Activator.CreateInstance(prop.PropertyType); + if (nestedInstance != null) + { + nestedInstance.FromDictionary(nestedDict); + prop.SetValue(this, nestedInstance); + } + continue; + } + // Detect non-YamlBase class types that can't be deserialized + else if (prop.PropertyType.IsClass && + prop.PropertyType != typeof(string) && + !prop.PropertyType.IsPrimitive) + { + throw new InvalidOperationException( + $"Property '{prop.Name}' of type '{prop.PropertyType.Name}' in class '{type.Name}' must inherit from YamlBase " + + $"for nested object deserialization. Custom classes that don't inherit from YamlBase cannot be automatically deserialized from YAML. " + + $"Either make '{prop.PropertyType.Name}' inherit from YamlBase, or use a primitive type or PSCustomObject."); + } + } + + // Handle arrays + if (prop.PropertyType.IsArray && value is IList list) + { + var elementType = prop.PropertyType.GetElementType(); + if (elementType != null) + { + // Handle arrays of YamlBase objects + if (typeof(YamlBase).IsAssignableFrom(elementType)) + { + var array = Array.CreateInstance(elementType, list.Count); + for (int i = 0; i < list.Count; i++) + { + if (list[i] is Dictionary itemDict) + { + var item = (YamlBase?)Activator.CreateInstance(elementType); + if (item != null) + { + item.FromDictionary(itemDict); + array.SetValue(item, i); + } + } + } + prop.SetValue(this, array); + continue; + } + // Detect arrays of non-YamlBase class types + else if (elementType.IsClass && + elementType != typeof(string) && + !elementType.IsPrimitive && + list.Count > 0 && + list[0] is Dictionary) + { + throw new InvalidOperationException( + $"Property '{prop.Name}' is an array of type '{elementType.Name}[]' in class '{type.Name}', " + + $"but '{elementType.Name}' does not inherit from YamlBase. " + + $"Custom class arrays must use types that inherit from YamlBase for deserialization. " + + $"Either make '{elementType.Name}' inherit from YamlBase, or use a primitive type array."); + } + // Handle arrays of primitive types (string[], int[], etc.) + else + { + var array = Array.CreateInstance(elementType, list.Count); + for (int i = 0; i < list.Count; i++) + { + var item = list[i]; + if (item != null) + { + try + { + var convertedItem = Convert.ChangeType(item, elementType); + array.SetValue(convertedItem, i); + } + catch + { + array.SetValue(item, i); + } + } + } + prop.SetValue(this, array); + continue; + } + } + } + + // Handle direct value assignment with type conversion + try + { + var convertedValue = Convert.ChangeType(value, prop.PropertyType); + prop.SetValue(this, convertedValue); + } + catch + { + // If conversion fails, try direct assignment + prop.SetValue(this, value); + } + } + } + + /// + /// Get the converter type from a property's YamlConverterAttribute without instantiating the attribute. + /// Uses GetCustomAttributesData to avoid ALC issues with PowerShell classes. + /// + private static Type? GetConverterTypeFromProperty(PropertyInfo prop) + { + foreach (var attrData in prop.GetCustomAttributesData()) + { + if (attrData.AttributeType == typeof(YamlConverterAttribute)) + { + // Get the constructor argument + if (attrData.ConstructorArguments.Count > 0) + { + var arg = attrData.ConstructorArguments[0]; + + // If it's a Type, validate and return it + if (arg.Value is Type type) + { + ValidateConverterType(type); + return type; + } + + // If it's a string type name, resolve it from the declaring type's assembly + if (arg.Value is string typeName) + { + return ResolveConverterType(typeName, prop.DeclaringType); + } + } + } + } + return null; + } + + /// + /// Resolve a converter type by name, searching only in the declaring type's assembly. + /// This ensures that user-defined converters in the same script/module always take precedence. + /// + private static Type ResolveConverterType(string typeName, Type? declaringType) + { + if (declaringType?.Assembly == null) + { + throw new InvalidOperationException( + $"Cannot resolve converter type '{typeName}' - declaring type has no assembly"); + } + + // For PowerShell classes: all classes in the same script/module share the same + // dynamic "PowerShell Class Assembly", so this will find converters defined alongside the class + Type? resolvedType; + try + { + resolvedType = declaringType.Assembly.GetType(typeName); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Error resolving converter type '{typeName}' in assembly '{declaringType.Assembly.FullName}'", + ex); + } + + if (resolvedType == null) + { + throw new InvalidOperationException( + $"Converter type '{typeName}' not found. " + + $"Make sure it's defined in the same file/module as your class."); + } + + ValidateConverterType(resolvedType); + return resolvedType; + } + + /// + /// Validate that a type implements IYamlTypeConverter interface. + /// + private static void ValidateConverterType(Type type) + { + if (!typeof(IYamlTypeConverter).IsAssignableFrom(type)) + { + throw new ArgumentException( + $"Type '{type.FullName}' must implement IYamlTypeConverter interface", + nameof(type)); + } + } + + /// + /// Get the YAML key for a property, checking for YamlKeyAttribute first. + /// + private static string GetYamlKeyForProperty(PropertyInfo prop) + { + // Check if property has YamlKeyAttribute + var yamlKeyAttr = prop.GetCustomAttribute(); + if (yamlKeyAttr != null) + { + return yamlKeyAttr.Key; + } + + // Fall back to automatic conversion + return ConvertPropertyNameToYamlKey(prop.Name); + } + + private static string ConvertPropertyNameToYamlKey(string propertyName) + { + // Convert PascalCase to hyphenated-case + // Example: AppName -> app-name, DatabaseHost -> database-host + var result = new System.Text.StringBuilder(); + for (int i = 0; i < propertyName.Length; i++) + { + char c = propertyName[i]; + if (char.IsUpper(c)) + { + if (i > 0) + { + result.Append('-'); + } + result.Append(char.ToLower(c)); + } + else + { + result.Append(c); + } + } + return result.ToString(); + } + + // Metadata access methods - use string keys to avoid ALC type identity issues + + public void SetPropertyComment(string propertyName, string? comment) + { + GetOrCreateMetadata(propertyName)["comment"] = comment; + } + + public string? GetPropertyComment(string propertyName) + { + return GetMetadataValue(propertyName, "comment") as string; + } + + public void SetPropertyScalarStyle(string propertyName, string? style) + { + GetOrCreateMetadata(propertyName)["scalarStyle"] = style; + } + + public string? GetPropertyScalarStyle(string propertyName) + { + return GetMetadataValue(propertyName, "scalarStyle") as string; + } + + public void SetPropertyMappingStyle(string propertyName, string? style) + { + GetOrCreateMetadata(propertyName)["mappingStyle"] = style; + } + + public string? GetPropertyMappingStyle(string propertyName) + { + return GetMetadataValue(propertyName, "mappingStyle") as string; + } + + /// + /// Set the mapping style for this document/object itself (not a property). + /// Used to preserve flow vs block style at the root level. + /// + public void SetDocumentMappingStyle(string? style) + { + GetOrCreateMetadata("")["mappingStyle"] = style; + } + + /// + /// Get the mapping style for this document/object itself (not a property). + /// + public string? GetDocumentMappingStyle() + { + return GetMetadataValue("", "mappingStyle") as string; + } + + public void SetPropertySequenceStyle(string propertyName, string? style) + { + GetOrCreateMetadata(propertyName)["sequenceStyle"] = style; + } + + public string? GetPropertySequenceStyle(string propertyName) + { + return GetMetadataValue(propertyName, "sequenceStyle") as string; + } + + public void SetPropertyTag(string propertyName, string? tag) + { + GetOrCreateMetadata(propertyName)["tag"] = tag; + } + + public string? GetPropertyTag(string propertyName) + { + return GetMetadataValue(propertyName, "tag") as string; + } + + /// + /// Get the full metadata dictionary for a property (for internal use by cmdlets) + /// + public Dictionary? GetPropertyMetadata(string propertyName) + { + return _metadata.TryGetValue(propertyName, out var meta) ? meta : null; + } + + /// + /// Set the full metadata dictionary for a property (for internal use by cmdlets) + /// + public void SetPropertyMetadata(string propertyName, Dictionary metadata) + { + _metadata[propertyName] = metadata; + } + + /// + /// Get all metadata for serialization + /// + public Dictionary> GetAllMetadata() + { + return _metadata; + } + + private Dictionary GetOrCreateMetadata(string propertyName) + { + if (!_metadata.TryGetValue(propertyName, out var meta)) + { + meta = new Dictionary(); + _metadata[propertyName] = meta; + } + return meta; + } + + private object? GetMetadataValue(string propertyName, string key) + { + if (_metadata.TryGetValue(propertyName, out var meta)) + { + return meta.TryGetValue(key, out var value) ? value : null; + } + return null; + } +} diff --git a/src/PowerShellYaml/YamlPropertyAttribute.cs b/src/PowerShellYaml/YamlPropertyAttribute.cs new file mode 100644 index 0000000..602c73c --- /dev/null +++ b/src/PowerShellYaml/YamlPropertyAttribute.cs @@ -0,0 +1,40 @@ +// Copyright 2016-2026 Cloudbase Solutions Srl +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// + +using System; + +namespace PowerShellYaml; + +/// +/// Attribute for configuring how a property is serialized to/from YAML. +/// +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public class YamlPropertyAttribute : Attribute +{ + /// + /// YAML property name alias (e.g., "server-name" for ServerName property). + /// + public string? Alias { get; set; } + + /// + /// Whether this property is required when deserializing. + /// + public bool Required { get; set; } + + /// + /// Comment to include when serializing to YAML. + /// + public string? Comment { get; set; } +} diff --git a/src/PowerShellYamlSerializer.cs b/src/PowerShellYamlSerializer.cs deleted file mode 100644 index b0f9433..0000000 --- a/src/PowerShellYamlSerializer.cs +++ /dev/null @@ -1,247 +0,0 @@ -using System; -using System.Numerics; -using System.Text.RegularExpressions; -using System.Collections; -using System.Management.Automation; -using System.Collections.Generic; -using YamlDotNet.Core; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.EventEmitters; -using YamlDotNet.Core.Events; -using YamlDotNet.Serialization.NamingConventions; -using YamlDotNet.Serialization.ObjectGraphVisitors; - -public sealed class NullValueGraphVisitor : ChainedObjectGraphVisitor -{ - public NullValueGraphVisitor(IObjectGraphVisitor nextVisitor) - : base(nextVisitor) - { - } - - public override bool EnterMapping(IPropertyDescriptor key, IObjectDescriptor value, IEmitter context, ObjectSerializer serializer) { - if (value.Value == null) { - return false; - } - return base.EnterMapping(key, value, context, serializer); - } - - public override bool EnterMapping(IObjectDescriptor key, IObjectDescriptor value, IEmitter context, ObjectSerializer serializer) { - if (value.Value == null) { - return false; - } - return base.EnterMapping(key, value, context, serializer); - } -} - -internal static class PSObjectHelper { - /// - /// Unwraps a PSObject to its BaseObject if the BaseObject is not a PSCustomObject. - /// - /// The object to potentially unwrap - /// The type of the unwrapped object - /// The unwrapped object if it was a PSObject wrapping a non-PSCustomObject, otherwise the original object - public static object UnwrapIfNeeded(object obj, out Type unwrappedType) { - if (obj is PSObject psObj && psObj.BaseObject != null) { - var baseType = psObj.BaseObject.GetType(); - if (baseType != typeof(System.Management.Automation.PSCustomObject)) { - unwrappedType = baseType; - return psObj.BaseObject; - } - } - unwrappedType = obj?.GetType(); - return obj; - } -} - -public class BigIntegerTypeConverter : IYamlTypeConverter { - public bool Accepts(Type type) { - return typeof(BigInteger).IsAssignableFrom(type); - } - - public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { - var value = parser.Consume().Value; - var bigNr = BigInteger.Parse(value); - return bigNr; - } - - public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { - var bigNr = (BigInteger)value; - emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, bigNr.ToString(), ScalarStyle.Plain, true, false)); - } -} - -public class IDictionaryTypeConverter : IYamlTypeConverter { - - private bool omitNullValues; - private bool useFlowStyle; - - public IDictionaryTypeConverter(bool omitNullValues = false, bool useFlowStyle = false) { - this.omitNullValues = omitNullValues; - this.useFlowStyle = useFlowStyle; - } - - public bool Accepts(Type type) { - return typeof(IDictionary).IsAssignableFrom(type); - } - - public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { - var deserializedObject = rootDeserializer(typeof(IDictionary)) as IDictionary; - return deserializedObject; - } - - public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { - var hObj = (IDictionary)value; - var mappingStyle = this.useFlowStyle ? MappingStyle.Flow : MappingStyle.Block; - - emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); - foreach (DictionaryEntry entry in hObj) { - if(entry.Value == null) { - if (this.omitNullValues) { - continue; - } - serializer(entry.Key, entry.Key.GetType()); - emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); - continue; - } - serializer(entry.Key, entry.Key.GetType()); - var unwrapped = PSObjectHelper.UnwrapIfNeeded(entry.Value, out var unwrappedType); - serializer(unwrapped, unwrappedType); - } - emitter.Emit(new MappingEnd()); - } -} - -public class PSObjectTypeConverter : IYamlTypeConverter { - - private bool omitNullValues; - private bool useFlowStyle; - - public PSObjectTypeConverter(bool omitNullValues = false, bool useFlowStyle = false) { - this.omitNullValues = omitNullValues; - this.useFlowStyle = useFlowStyle; - } - - public bool Accepts(Type type) { - return typeof(PSObject).IsAssignableFrom(type); - } - - public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - // We don't really need to do any custom deserialization. - var deserializedObject = rootDeserializer(typeof(IDictionary)) as IDictionary; - return deserializedObject; - } - - public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { - var psObj = (PSObject)value; - if (psObj.BaseObject != null && - !typeof(IDictionary).IsAssignableFrom(psObj.BaseObject.GetType()) && - !typeof(PSCustomObject).IsAssignableFrom(psObj.BaseObject.GetType())) { - serializer(psObj.BaseObject, psObj.BaseObject.GetType()); - return; - } - var mappingStyle = this.useFlowStyle ? MappingStyle.Flow : MappingStyle.Block; - emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); - foreach (var prop in psObj.Properties) { - if (prop.Value == null) { - if (this.omitNullValues) { - continue; - } - serializer(prop.Name, prop.Name.GetType()); - emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); - } else { - serializer(prop.Name, prop.Name.GetType()); - var unwrapped = PSObjectHelper.UnwrapIfNeeded(prop.Value, out var unwrappedType); - serializer(unwrapped, unwrappedType); - } - } - emitter.Emit(new MappingEnd()); - } -} - -public class StringQuotingEmitter: ChainedEventEmitter { - // Patterns from https://yaml.org/spec/1.2/spec.html#id2804356 - private static Regex quotedRegex = new Regex(@"^(\~|null|true|false|on|off|yes|no|y|n|[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?|[-+]?(\.inf))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - public StringQuotingEmitter(IEventEmitter next): base(next) {} - - public override void Emit(ScalarEventInfo eventInfo, IEmitter emitter) { - var typeCode = eventInfo.Source.Value != null - ? Type.GetTypeCode(eventInfo.Source.Type) - : TypeCode.Empty; - - switch (typeCode) { - case TypeCode.Char: - if (Char.IsDigit((char)eventInfo.Source.Value)) { - eventInfo.Style = ScalarStyle.DoubleQuoted; - } - break; - case TypeCode.String: - var val = eventInfo.Source.Value.ToString(); - if (quotedRegex.IsMatch(val)) - { - eventInfo.Style = ScalarStyle.DoubleQuoted; - } else if (val.IndexOf('\n') > -1) { - eventInfo.Style = ScalarStyle.Literal; - } - break; - } - - base.Emit(eventInfo, emitter); - } -} - -public class FlowStyleAllEmitter: ChainedEventEmitter { - public FlowStyleAllEmitter(IEventEmitter next): base(next) {} - - public override void Emit(MappingStartEventInfo eventInfo, IEmitter emitter) { - eventInfo.Style = MappingStyle.Flow; - base.Emit(eventInfo, emitter); - } - - public override void Emit(SequenceStartEventInfo eventInfo, IEmitter emitter){ - eventInfo.Style = SequenceStyle.Flow; - base.Emit(eventInfo, emitter); - } -} - -public class FlowStyleSequenceEmitter: ChainedEventEmitter { - public FlowStyleSequenceEmitter(IEventEmitter next): base(next) {} - - public override void Emit(SequenceStartEventInfo eventInfo, IEmitter emitter){ - eventInfo.Style = SequenceStyle.Flow; - base.Emit(eventInfo, emitter); - } -} - -public class BuilderUtils { - public static SerializerBuilder BuildSerializer( - SerializerBuilder builder, - bool omitNullValues = false, - bool useFlowStyle = false, - bool useSequenceFlowStyle = false, - bool jsonCompatible = false) { - - if (jsonCompatible) { - useFlowStyle = true; - useSequenceFlowStyle = true; - } - - builder = builder - .WithEventEmitter(next => new StringQuotingEmitter(next)) - .WithTypeConverter(new BigIntegerTypeConverter()) - .WithTypeConverter(new IDictionaryTypeConverter(omitNullValues, useFlowStyle)) - .WithTypeConverter(new PSObjectTypeConverter(omitNullValues, useFlowStyle)); - if (omitNullValues) { - builder = builder - .WithEmissionPhaseObjectGraphVisitor(args => new NullValueGraphVisitor(args.InnerVisitor)); - } - if (useFlowStyle) { - builder = builder.WithEventEmitter(next => new FlowStyleAllEmitter(next)); - } - if (useSequenceFlowStyle) { - builder = builder.WithEventEmitter(next => new FlowStyleSequenceEmitter(next)); - } - - return builder; - } -} diff --git a/src/PowerShellYamlSerializer.csproj b/src/PowerShellYamlSerializer.csproj deleted file mode 100644 index a38a57d..0000000 --- a/src/PowerShellYamlSerializer.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - netstandard2.0 - - - - - - - - - From 51eb4814b773f4c78924559f0494d90334859fc9 Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Mon, 5 Jan 2026 12:33:07 +0200 Subject: [PATCH 2/3] Add -Depth option Signed-off-by: Gabriel Adrian Samfira --- lib/netstandard2.0/PowerShellYaml.Module.dll | Bin 30720 -> 32256 bytes lib/netstandard2.0/PowerShellYaml.dll | Bin 15360 -> 15360 bytes powershell-yaml.psm1 | 19 +- .../LegacySerializers.cs | 216 +++++++++++++----- .../TypedYamlConverter.cs | 38 ++- 5 files changed, 198 insertions(+), 75 deletions(-) diff --git a/lib/netstandard2.0/PowerShellYaml.Module.dll b/lib/netstandard2.0/PowerShellYaml.Module.dll index 9f2f27b58b5f5c6a2c5749ce5e3a7ccb4a6c2bd3..ed6590582328f50c7dbd43905d68c4f1b49d1b35 100644 GIT binary patch literal 32256 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 diff --git a/lib/netstandard2.0/PowerShellYaml.dll b/lib/netstandard2.0/PowerShellYaml.dll index a15eed2447e47f31d1f5bef493d91ce0cc160982..5092bda13a23aa7c153950a8f05cb6084395fa03 100644 GIT binary patch delta 235 zcmZpuXsDRb!O~uR@!`fE5j_D(*1FlV?zDR@J`%O^#qCFrHgC|&X4Wt;HaAN#N;OY4 zH8eIiv`k4gH@8T%FiT4`Hb^l}OiWBNNl7$JO-Y&TZ1k67Gd;%^UQxnKe?4%qER)iVOpT1wQcROmEz>4D8~tUuyjisirZv z9QF$KevIPxI5~NVais!Ou)!882vp72x@Vg+YCsX14WF1a)u1a42eL#A&^V~(n$@eQOJM*2 diff --git a/powershell-yaml.psm1 b/powershell-yaml.psm1 index da26d1a..6ee2512 100644 --- a/powershell-yaml.psm1 +++ b/powershell-yaml.psm1 @@ -506,7 +506,8 @@ function ConvertFrom-Yaml { function Get-Serializer { param( - [Parameter(Mandatory = $true)][SerializationOptions]$Options + [Parameter(Mandatory = $true)][SerializationOptions]$Options, + [int]$MaxDepth = 100 ) $builder = $yamlDotNetAssembly.GetType('YamlDotNet.Serialization.SerializerBuilder')::new() @@ -532,13 +533,16 @@ function Get-Serializer { $builder = $builder.WithIndentedSequences() } + # Set a high recursion limit - our custom visitors handle depth limiting and circular references + $builder = $builder.WithMaximumRecursion(1000) + $omitNull = $Options.HasFlag([SerializationOptions]::OmitNullValues) $useFlowStyle = $Options.HasFlag([SerializationOptions]::UseFlowStyle) $useSequenceFlowStyle = $Options.HasFlag([SerializationOptions]::UseSequenceFlowStyle) $useBlockStyle = $Options.HasFlag([SerializationOptions]::UseBlockStyle) $useSequenceBlockStyle = $Options.HasFlag([SerializationOptions]::UseSequenceBlockStyle) - $builder = $script:BuilderUtils::BuildSerializer($builder, $omitNull, $useFlowStyle, $useSequenceFlowStyle, $useBlockStyle, $useSequenceBlockStyle, $JsonCompatible) + $builder = $script:BuilderUtils::BuildSerializer($builder, $omitNull, $useFlowStyle, $useSequenceFlowStyle, $useBlockStyle, $useSequenceBlockStyle, $JsonCompatible, $MaxDepth) return $builder.Build() } @@ -564,7 +568,10 @@ function ConvertTo-Yaml { # Typed YAML parameters (for YamlBase objects) [switch]$OmitNull, - [switch]$EmitTags + [switch]$EmitTags, + + # Maximum recursion depth (default: 50) + [int]$Depth = 100 ) begin { $d = [System.Collections.Generic.List[object]](New-Object 'System.Collections.Generic.List[object]') @@ -598,7 +605,7 @@ function ConvertTo-Yaml { $useSequenceBlockStyle = $Options.HasFlag([SerializationOptions]::UseSequenceBlockStyle) $indentedSequences = $Options.HasFlag([SerializationOptions]::WithIndentedSequences) } - $yaml = $script:TypedYamlConverter::ToYaml($d, $OmitNull.IsPresent, $EmitTags.IsPresent, $useFlowStyle, $useBlockStyle, $useSequenceFlowStyle, $useSequenceBlockStyle, $indentedSequences) + $yaml = $script:TypedYamlConverter::ToYaml($d, $OmitNull.IsPresent, $EmitTags.IsPresent, $useFlowStyle, $useBlockStyle, $useSequenceFlowStyle, $useSequenceBlockStyle, $indentedSequences, $Depth) } else { throw "Typed YAML module not loaded" } @@ -610,7 +617,7 @@ function ConvertTo-Yaml { if ($PSCmdlet.ParameterSetName -eq 'Options') { $indentedSequences = $Options.HasFlag([SerializationOptions]::WithIndentedSequences) } - $yaml = $MetadataAwareSerializer::Serialize($d, $indentedSequences, $EmitTags.IsPresent) + $yaml = $MetadataAwareSerializer::Serialize($d, $indentedSequences, $EmitTags.IsPresent, $Depth) } else { $wrt = New-Object 'System.IO.StringWriter' $norm = Convert-PSObjectToGenericObject $d @@ -622,7 +629,7 @@ function ConvertTo-Yaml { } } try { - $serializer = Get-Serializer $Options + $serializer = Get-Serializer -Options $Options -MaxDepth $Depth $serializer.Serialize($wrt, $norm) $yaml = $wrt.ToString() } finally { diff --git a/src/PowerShellYaml.Module/LegacySerializers.cs b/src/PowerShellYaml.Module/LegacySerializers.cs index 05aa9b0..c6eee13 100644 --- a/src/PowerShellYaml.Module/LegacySerializers.cs +++ b/src/PowerShellYaml.Module/LegacySerializers.cs @@ -30,28 +30,23 @@ namespace PowerShellYaml.Module; using YamlDotNet.Core.Events; using YamlDotNet.Serialization.NamingConventions; using YamlDotNet.Serialization.ObjectGraphVisitors; +using YamlDotNet.Serialization.ObjectGraphTraversalStrategies; +using YamlDotNet.Serialization.ObjectFactories; using YamlDotNet.RepresentationModel; -public sealed class NullValueGraphVisitor : ChainedObjectGraphVisitor -{ - public NullValueGraphVisitor(IObjectGraphVisitor nextVisitor) - : base(nextVisitor) - { - } - public override bool EnterMapping(IPropertyDescriptor key, IObjectDescriptor value, IEmitter context, ObjectSerializer serializer) { - if (value.Value == null) { - return false; - } - return base.EnterMapping(key, value, context, serializer); - } +/// +/// Shared depth tracker for type converters +/// Thread-static to ensure thread safety +/// +internal static class SharedDepthTracker { + [ThreadStatic] + private static int currentDepth; - public override bool EnterMapping(IObjectDescriptor key, IObjectDescriptor value, IEmitter context, ObjectSerializer serializer) { - if (value.Value == null) { - return false; - } - return base.EnterMapping(key, value, context, serializer); - } + public static int CurrentDepth => currentDepth; + + public static void Increment() => currentDepth++; + public static void Decrement() => currentDepth--; } internal static class PSObjectHelper { @@ -95,10 +90,12 @@ public class IDictionaryTypeConverter : IYamlTypeConverter { private bool omitNullValues; private bool useFlowStyle; + private readonly int maxDepth; - public IDictionaryTypeConverter(bool omitNullValues = false, bool useFlowStyle = false) { + public IDictionaryTypeConverter(bool omitNullValues = false, bool useFlowStyle = false, int maxDepth = 100) { this.omitNullValues = omitNullValues; this.useFlowStyle = useFlowStyle; + this.maxDepth = maxDepth; } public bool Accepts(Type type) { @@ -114,32 +111,51 @@ public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerialize var hObj = (IDictionary)value; var mappingStyle = this.useFlowStyle ? MappingStyle.Flow : MappingStyle.Block; - emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); - foreach (DictionaryEntry entry in hObj) { - if(entry.Value == null) { - if (this.omitNullValues) { + SharedDepthTracker.Increment(); + try { + // Check if we've exceeded the depth limit + if (SharedDepthTracker.CurrentDepth > maxDepth) { + // Emit empty object as we're too deep + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, MappingStyle.Flow)); + emitter.Emit(new MappingEnd()); + return; + } + + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); + + foreach (DictionaryEntry entry in hObj) { + if(entry.Value == null) { + if (this.omitNullValues) { + continue; + } + serializer(entry.Key, entry.Key.GetType()); + emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); continue; } + serializer(entry.Key, entry.Key.GetType()); - emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); - continue; + + var unwrapped = PSObjectHelper.UnwrapIfNeeded(entry.Value, out var unwrappedType); + serializer(unwrapped, unwrappedType); } - serializer(entry.Key, entry.Key.GetType()); - var unwrapped = PSObjectHelper.UnwrapIfNeeded(entry.Value, out var unwrappedType); - serializer(unwrapped, unwrappedType); + } finally { + SharedDepthTracker.Decrement(); } + emitter.Emit(new MappingEnd()); } } public class PSObjectTypeConverter : IYamlTypeConverter { - private bool omitNullValues; - private bool useFlowStyle; + private readonly bool omitNullValues; + private readonly bool useFlowStyle; + private readonly int maxDepth; - public PSObjectTypeConverter(bool omitNullValues = false, bool useFlowStyle = false) { + public PSObjectTypeConverter(bool omitNullValues = false, bool useFlowStyle = false, int maxDepth = 100) { this.omitNullValues = omitNullValues; this.useFlowStyle = useFlowStyle; + this.maxDepth = maxDepth; } public bool Accepts(Type type) { @@ -162,21 +178,38 @@ public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerialize return; } var mappingStyle = this.useFlowStyle ? MappingStyle.Flow : MappingStyle.Block; - emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); - foreach (var prop in psObj.Properties) { - if (prop.Value == null) { - if (this.omitNullValues) { - continue; + + SharedDepthTracker.Increment(); + try { + // Check if we've exceeded the depth limit + if (SharedDepthTracker.CurrentDepth > maxDepth) { + // Emit empty object as we're too deep + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, MappingStyle.Flow)); + emitter.Emit(new MappingEnd()); + return; + } + + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); + + foreach (var prop in psObj.Properties) { + if (prop.Value == null) { + if (this.omitNullValues) { + continue; + } + serializer(prop.Name, prop.Name.GetType()); + emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); + } else { + serializer(prop.Name, prop.Name.GetType()); + + var unwrapped = PSObjectHelper.UnwrapIfNeeded(prop.Value, out var unwrappedType); + serializer(unwrapped, unwrappedType); } - serializer(prop.Name, prop.Name.GetType()); - emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); - } else { - serializer(prop.Name, prop.Name.GetType()); - var unwrapped = PSObjectHelper.UnwrapIfNeeded(prop.Value, out var unwrappedType); - serializer(unwrapped, unwrappedType); } + + emitter.Emit(new MappingEnd()); + } finally { + SharedDepthTracker.Decrement(); } - emitter.Emit(new MappingEnd()); } } @@ -230,6 +263,53 @@ public override void Emit(SequenceStartEventInfo eventInfo, IEmitter emitter){ } } +/// +/// Custom traversal strategy that limits recursion depth +/// Note: This only affects objects that don't have custom type converters. +/// Hashtables and PSCustomObjects use type converters that bypass this strategy. +/// +public class DepthLimitingTraversalStrategy( + ITypeInspector typeInspector, + ITypeResolver typeResolver, + int maxRecursion, + INamingConvention namingConvention, + IObjectFactory objectFactory) : FullObjectGraphTraversalStrategy(typeInspector, typeResolver, 1000, namingConvention, objectFactory) +{ + private readonly int _maxDepth = maxRecursion; + + protected override void Traverse( + IPropertyDescriptor propertyDescriptor, + object value, + IObjectDescriptor valueDescriptor, + IObjectGraphVisitor visitor, + TContext context, + Stack path, + ObjectSerializer serializer) + { + int maxDepth = _maxDepth; + if (maxDepth == 0) + { + maxDepth = 1; + } + + // Check if we should skip this property due to depth limit + // Use path.Count as fallback for .NET objects that don't go through type converters + // path.Count starts at 1 for root properties, so subtract 1 to get 0-based depth + int effectiveDepth = Math.Max(SharedDepthTracker.CurrentDepth, path.Count - 1); + + // Skip if we're beyond the depth limit + // Note: depth 0 = root object properties, depth 1 = nested properties, etc. + if(effectiveDepth > maxDepth) + { + // Return here and do not traverse. Max depth reached. + return; + } + + // Call base implementation to do the actual traversal + base.Traverse(propertyDescriptor, value, valueDescriptor, visitor, context, path, serializer); + } +} + public class BuilderUtils { public static SerializerBuilder BuildSerializer( SerializerBuilder builder, @@ -238,7 +318,8 @@ public static SerializerBuilder BuildSerializer( bool useSequenceFlowStyle = false, bool useBlockStyle = false, bool useSequenceBlockStyle = false, - bool jsonCompatible = false) { + bool jsonCompatible = false, + int maxDepth = 100) { if (jsonCompatible) { useFlowStyle = true; @@ -253,15 +334,24 @@ public static SerializerBuilder BuildSerializer( useSequenceFlowStyle = false; } + // Use custom traversal strategy for depth limiting + // Note: This only affects objects without custom type converters + builder = builder.WithObjectGraphTraversalStrategyFactory((typeInspector, typeResolver, typeConverters, maximumRecursion) => + new DepthLimitingTraversalStrategy( + typeInspector, + typeResolver, + maxDepth, + NullNamingConvention.Instance, + new DefaultObjectFactory() + ) + ); + builder = builder .WithEventEmitter(next => new StringQuotingEmitter(next)) .WithTypeConverter(new BigIntegerTypeConverter()) - .WithTypeConverter(new IDictionaryTypeConverter(omitNullValues, useFlowStyle)) - .WithTypeConverter(new PSObjectTypeConverter(omitNullValues, useFlowStyle)); - if (omitNullValues) { - builder = builder - .WithEmissionPhaseObjectGraphVisitor(args => new NullValueGraphVisitor(args.InnerVisitor)); - } + .WithTypeConverter(new IDictionaryTypeConverter(omitNullValues, useFlowStyle, maxDepth)) + .WithTypeConverter(new PSObjectTypeConverter(omitNullValues, useFlowStyle, maxDepth)); + if (useFlowStyle) { builder = builder.WithEventEmitter(next => new FlowStyleAllEmitter(next)); } @@ -836,7 +926,7 @@ private static string GetTagFromType(object value) { }; } - public static string Serialize(PSObject obj, bool indentedSequences = false, bool emitTags = false) { + public static string Serialize(PSObject obj, bool indentedSequences = false, bool emitTags = false, int maxDepth = 100) { var metadata = PSObjectMetadataExtensions.GetMetadata(obj); if (metadata == null) { throw new InvalidOperationException("Object does not have YAML metadata"); @@ -864,7 +954,7 @@ public static string Serialize(PSObject obj, bool indentedSequences = false, boo emitter.Emit(new StreamStart()); emitter.Emit(new DocumentStart()); - SerializePSObject(obj, metadata, emitter, MappingStyle.Block, emitTags); + SerializePSObject(obj, metadata, emitter, MappingStyle.Block, emitTags, 0, maxDepth); emitter.Emit(new DocumentEnd(true)); emitter.Emit(new StreamEnd()); @@ -872,7 +962,13 @@ public static string Serialize(PSObject obj, bool indentedSequences = false, boo return stringWriter.ToString(); } - private static void SerializePSObject(PSObject obj, YamlMetadataStore metadata, IEmitter emitter, MappingStyle style = MappingStyle.Block, bool emitTags = false) { + private static void SerializePSObject(PSObject obj, YamlMetadataStore metadata, IEmitter emitter, MappingStyle style = MappingStyle.Block, bool emitTags = false, int currentDepth = 0, int maxDepth = 100) { + if (currentDepth >= maxDepth) { + // Emit empty object as default value for the truncated type + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, MappingStyle.Flow)); + emitter.Emit(new MappingEnd()); + return; + } emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, style)); foreach (var prop in obj.Properties) { @@ -900,11 +996,11 @@ private static void SerializePSObject(PSObject obj, YamlMetadataStore metadata, // Recursively serialize nested enhanced objects var nestedMetadata = metadata.GetNestedMetadata(prop.Name); var nestedMappingStyle = metadata.GetPropertyMappingStyle(prop.Name) ?? MappingStyle.Block; - SerializePSObject(nestedPSObj, nestedMetadata, emitter, nestedMappingStyle, emitTags); + SerializePSObject(nestedPSObj, nestedMetadata, emitter, nestedMappingStyle, emitTags, currentDepth + 1, maxDepth); } else if (prop.Value is IList list) { var sequenceStyle = metadata.GetPropertySequenceStyle(prop.Name) ?? SequenceStyle.Block; - SerializeList(list, metadata.GetNestedMetadata(prop.Name), emitter, sequenceStyle, emitTags); + SerializeList(list, metadata.GetNestedMetadata(prop.Name), emitter, sequenceStyle, emitTags, currentDepth + 1, maxDepth); } else if (prop.Value is bool boolValue) { // If emitTags is enabled and no stored tag, infer tag from type @@ -956,7 +1052,13 @@ private static void SerializePSObject(PSObject obj, YamlMetadataStore metadata, emitter.Emit(new MappingEnd()); } - private static void SerializeList(IList list, YamlMetadataStore metadata, IEmitter emitter, SequenceStyle style = SequenceStyle.Block, bool emitTags = false) { + private static void SerializeList(IList list, YamlMetadataStore metadata, IEmitter emitter, SequenceStyle style = SequenceStyle.Block, bool emitTags = false, int currentDepth = 0, int maxDepth = 100) { + if (currentDepth >= maxDepth) { + // Emit empty array as default value for the truncated type + emitter.Emit(new SequenceStart(AnchorName.Empty, TagName.Empty, true, SequenceStyle.Flow)); + emitter.Emit(new SequenceEnd()); + return; + } emitter.Emit(new SequenceStart(AnchorName.Empty, TagName.Empty, true, style)); for (int i = 0; i < list.Count; i++) { @@ -967,7 +1069,7 @@ private static void SerializeList(IList list, YamlMetadataStore metadata, IEmitt } else if (item is PSObject nestedPSObj && PSObjectMetadataExtensions.IsEnhancedPSCustomObject(nestedPSObj)) { var itemMetadata = metadata.GetNestedMetadata($"[{i}]"); - SerializePSObject(nestedPSObj, itemMetadata, emitter, MappingStyle.Block, emitTags); + SerializePSObject(nestedPSObj, itemMetadata, emitter, MappingStyle.Block, emitTags, currentDepth + 1, maxDepth); } else if (item is bool boolValue) { // If emitTags is enabled, infer tag from type diff --git a/src/PowerShellYaml.Module/TypedYamlConverter.cs b/src/PowerShellYaml.Module/TypedYamlConverter.cs index 0ba9eb9..97715ae 100644 --- a/src/PowerShellYaml.Module/TypedYamlConverter.cs +++ b/src/PowerShellYaml.Module/TypedYamlConverter.cs @@ -294,7 +294,7 @@ internal static void ValidateDuplicateKeysHaveExplicitMappings(Dictionary /// Serialize a YamlBase object to YAML string. /// - public static string ToYaml(YamlBase obj, bool omitNull = false, bool emitTags = false, bool useFlowStyle = false, bool useBlockStyle = false, bool useSequenceFlowStyle = false, bool useSequenceBlockStyle = false, bool indentedSequences = false) + public static string ToYaml(YamlBase obj, bool omitNull = false, bool emitTags = false, bool useFlowStyle = false, bool useBlockStyle = false, bool useSequenceFlowStyle = false, bool useSequenceBlockStyle = false, bool indentedSequences = false, int maxDepth = 100) { if (obj is null) { @@ -322,10 +322,10 @@ public static string ToYaml(YamlBase obj, bool omitNull = false, bool emitTags = sequenceStyleOverride = SequenceStyle.Flow; } - return SerializeWithMetadata(obj, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, indentedSequences); + return SerializeWithMetadata(obj, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, indentedSequences, maxDepth); } - public static string SerializeWithMetadata(YamlBase obj, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride, bool indentedSequences = false) + public static string SerializeWithMetadata(YamlBase obj, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride, bool indentedSequences = false, int maxDepth = 100) { var stringWriter = new StringWriter(); @@ -354,7 +354,7 @@ public static string SerializeWithMetadata(YamlBase obj, bool emitTags, bool omi emitter.Emit(new StreamStart()); emitter.Emit(new DocumentStart()); - SerializeObject(obj, emitter, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride); + SerializeObject(obj, emitter, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, 0, maxDepth); emitter.Emit(new DocumentEnd(true)); // true = implicit (no "..." marker) emitter.Emit(new StreamEnd()); @@ -362,8 +362,15 @@ public static string SerializeWithMetadata(YamlBase obj, bool emitTags, bool omi return stringWriter.ToString(); } - private static void SerializeObject(YamlBase obj, IEmitter emitter, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride) + private static void SerializeObject(YamlBase obj, IEmitter emitter, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride, int currentDepth, int maxDepth) { + if (currentDepth >= maxDepth) + { + // Emit a placeholder string to indicate max depth reached + emitter.Emit(new Scalar("...")); + return; + } + // Determine mapping style with precedence: // 1. mappingStyleOverride (if set by user) // 2. Object metadata (document-level mapping style) @@ -379,10 +386,10 @@ private static void SerializeObject(YamlBase obj, IEmitter emitter, bool emitTag var documentStyleStr = obj.GetDocumentMappingStyle(); mappingStyle = documentStyleStr == "Flow" ? MappingStyle.Flow : MappingStyle.Block; } - SerializeObjectWithStyle(obj, emitter, emitTags, omitNull, mappingStyle, mappingStyleOverride, sequenceStyleOverride); + SerializeObjectWithStyle(obj, emitter, emitTags, omitNull, mappingStyle, mappingStyleOverride, sequenceStyleOverride, currentDepth, maxDepth); } - private static void SerializeObjectWithStyle(YamlBase obj, IEmitter emitter, bool emitTags, bool omitNull, MappingStyle mappingStyle, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride) + private static void SerializeObjectWithStyle(YamlBase obj, IEmitter emitter, bool emitTags, bool omitNull, MappingStyle mappingStyle, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride, int currentDepth, int maxDepth) { var dict = obj.ToDictionary(); var metadata = obj.GetAllMetadata(); @@ -424,13 +431,13 @@ private static void SerializeObjectWithStyle(YamlBase obj, IEmitter emitter, boo emitter.Emit(new Scalar(yamlKey)); // Emit value with metadata - EmitValue(value, emitter, obj, propName, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride); + EmitValue(value, emitter, obj, propName, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, currentDepth, maxDepth); } emitter.Emit(new MappingEnd()); } - private static void EmitValue(object? value, IEmitter emitter, YamlBase? parentObj, string? propertyName, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride) + private static void EmitValue(object? value, IEmitter emitter, YamlBase? parentObj, string? propertyName, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride, int currentDepth, int maxDepth) { if (value is null) { @@ -439,6 +446,13 @@ private static void EmitValue(object? value, IEmitter emitter, YamlBase? parentO return; } + // Check depth limit for nested structures + if (currentDepth >= maxDepth) + { + emitter.Emit(new Scalar("...")); + return; + } + // Handle nested YamlBase objects if (value is YamlBase nestedYaml) { @@ -461,7 +475,7 @@ private static void EmitValue(object? value, IEmitter emitter, YamlBase? parentO mappingStyle = MappingStyle.Block; } - SerializeObjectWithStyle(nestedYaml, emitter, emitTags, omitNull, mappingStyle, mappingStyleOverride, sequenceStyleOverride); + SerializeObjectWithStyle(nestedYaml, emitter, emitTags, omitNull, mappingStyle, mappingStyleOverride, sequenceStyleOverride, currentDepth + 1, maxDepth); return; } @@ -494,7 +508,7 @@ private static void EmitValue(object? value, IEmitter emitter, YamlBase? parentO } emitter.Emit(new Scalar(kvp.Key)); - EmitValue(kvp.Value, emitter, null, null, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride); + EmitValue(kvp.Value, emitter, null, null, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, currentDepth + 1, maxDepth); } emitter.Emit(new MappingEnd()); return; @@ -526,7 +540,7 @@ private static void EmitValue(object? value, IEmitter emitter, YamlBase? parentO foreach (var item in list) { // Note: We don't skip null items in arrays - preserve array structure - EmitValue(item, emitter, null, null, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride); + EmitValue(item, emitter, null, null, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, currentDepth + 1, maxDepth); } emitter.Emit(new SequenceEnd()); return; From 2c63ad657cc73babcb1c7f8c52aa0b2165e3e32e Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Mon, 5 Jan 2026 13:32:57 +0200 Subject: [PATCH 3/3] Round trip flow style for PSCustomObject Signed-off-by: Gabriel Adrian Samfira --- examples/README.md | 494 ------------------ lib/netstandard2.0/PowerShellYaml.Module.dll | Bin 32256 -> 32256 bytes lib/netstandard2.0/PowerShellYaml.dll | Bin 15360 -> 15360 bytes powershell-yaml.psd1 | 10 + powershell-yaml.psm1 | 8 +- .../LegacySerializers.cs | 15 +- 6 files changed, 29 insertions(+), 498 deletions(-) delete mode 100644 examples/README.md diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 06db0c4..0000000 --- a/examples/README.md +++ /dev/null @@ -1,494 +0,0 @@ -# PowerShell-YAML Examples - -This directory contains examples demonstrating the features of PowerShell-YAML with typed class support. - -## Quick Start - -All examples require importing the module: - -```powershell -Import-Module powershell-yaml -``` - -## Examples - -### 1. typed-yaml-demo.ps1 - -**Comprehensive introduction to typed YAML serialization** - -Demonstrates: -- Creating PowerShell classes that inherit from `YamlBase` -- Type-safe deserialization with `-As` parameter -- Nested objects (`AppConfig` → `DatabaseConfig`) -- Array handling (`AllowedOrigins`) -- Round-trip serialization -- Automatic property name conversion (PascalCase → hyphenated-case) - -**Classes**: `classes/DemoClasses.ps1` (`DatabaseConfig`, `AppConfig`) - -**Run**: -```powershell -pwsh -File examples/typed-yaml-demo.ps1 -``` - -### 2. yamlkey-attribute.ps1 - -**YamlKey attribute for case-sensitive YAML keys** - -Demonstrates: -- Using `[YamlKey("key-name")]` attribute -- Mapping case-sensitive YAML keys to different properties -- Solving PowerShell's case-insensitive property limitation - -**Classes**: `classes/ServerConfig.ps1` (`ServerConfig`) - -**Run**: -```powershell -pwsh -File examples/yamlkey-attribute.ps1 -``` - -**Example**: -```powershell -class ServerConfig : YamlBase { - [YamlKey("Host")] # Maps to "Host" in YAML - [string]$PrimaryHost - - [YamlKey("host")] # Maps to "host" in YAML - [string]$BackupHost -} -``` - -### 3. metadata-demo.ps1 - -**YAML metadata preservation (comments, tags, styles)** - -Demonstrates: -- Automatic comment preservation from source YAML -- YAML tag preservation (`!!int`, `!!str`) -- Scalar style preservation (plain, single-quoted, double-quoted) -- Programmatic metadata manipulation via `Get/SetProperty*` methods -- Metadata survival through round-trip serialization - -**Classes**: `classes/DemoClasses.ps1` - -**Run**: -```powershell -pwsh -File examples/metadata-demo.ps1 -``` - -**Example**: -```powershell -# Comments and tags are automatically preserved -$yaml = @" -# Application configuration -app-name: "MyApp" -port: !!int "8080" -"@ - -$config = $yaml | ConvertFrom-YamlTyped -As ([AppConfig]) - -# Access metadata -$config.GetPropertyComment('AppName') # Returns: "Application configuration" -$config.GetPropertyTag('Port') # Returns: "tag:yaml.org,2002:int" - -# Add metadata -$config.SetPropertyComment('Environment', 'Deployment target') - -# Metadata preserved when serializing -$newYaml = $config | ConvertTo-YamlTyped -``` - -### 4. duplicate-key-detection.ps1 - -**Duplicate key detection and prevention** - -Demonstrates: -- PSCustomObject mode rejecting case-insensitive duplicate keys -- Typed mode validation requiring explicit `[YamlKey]` mappings -- Preventing silent data loss from key overwrites -- Handling multiple case variations (`test`, `Test`, `TEST`) -- Round-trip preservation of case-sensitive keys - -**Classes**: `classes/DuplicateKeyClasses.ps1` (`ConfigWithoutMapping`, `ConfigWithMapping`, `ThreeVariations`) - -**Run**: -```powershell -pwsh -File examples/duplicate-key-detection.ps1 -``` - -**Example**: -```powershell -# This YAML has case-insensitive duplicate keys -$yaml = @" -test: hello -Test: world -"@ - -# PSCustomObject mode prevents data loss -$yaml | ConvertFrom-Yaml -As ([PSCustomObject]) # Throws error - -# Typed mode without mappings also fails -class BadConfig : YamlBase { - [string]$test -} -$yaml | ConvertFrom-Yaml -As ([BadConfig]) # Throws error - -# Typed mode with explicit mappings succeeds -class GoodConfig : YamlBase { - [YamlKey("test")] - [string]$LowercaseValue - - [YamlKey("Test")] - [string]$CapitalizedValue -} -$config = $yaml | ConvertFrom-Yaml -As ([GoodConfig]) -# $config.LowercaseValue = "hello" -# $config.CapitalizedValue = "world" -``` - -### 5. advanced-features.ps1 - -**All features combined in a realistic scenario** - -Demonstrates: -- `[YamlKey]` attribute for case-sensitive keys -- Custom YAML key mapping -- Nested `YamlBase` objects -- Arrays of `YamlBase` objects -- Automatic PascalCase → hyphenated-case conversion -- Full metadata preservation -- Complete round-trip with all features working together - -**Classes**: `classes/AdvancedConfig.ps1` (`ServerEndpoint`, `AdvancedConfig`) - -**Run**: -```powershell -pwsh -File examples/advanced-features.ps1 -``` - -### 6. custom-converters.ps1 - -**Custom Type Converters - Extending YAML with Application-Specific Types** - -Demonstrates: -- Creating custom converters by inheriting from `YamlConverter` -- Using `[YamlConverter("ConverterName")]` attribute to register converters -- Handling custom YAML tags (`!semver`, `!datetime`) -- Supporting multiple input formats (string and dictionary) -- Overriding standard YAML type handling -- Full round-trip with custom tag preservation -- Error handling for invalid input - -**Classes**: `classes/CustomConverters.ps1` (`SemanticVersion`, `SemVerConverter`, `CustomDateTimeConverter`, `AppRelease`) - -**Run**: -```powershell -pwsh -File examples/custom-converters.ps1 -``` - -**Example**: -```powershell -# Define a custom type -class SemanticVersion { - [int]$Major = 0 - [int]$Minor = 0 - [int]$Patch = 0 - [string]$PreRelease = "" -} - -# Create a converter -class SemVerConverter : YamlConverter { - [bool] CanHandle([string]$tag, [Type]$targetType) { - return $targetType -eq [SemanticVersion] - } - - [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { - # Parse YAML data into SemanticVersion - # ... - } - - [object] ConvertToYaml([object]$value) { - # Return hashtable with Value and Tag - return @{ - Value = $value.ToString() - Tag = '!semver' - } - } -} - -# Use the converter -class AppRelease : YamlBase { - [YamlConverter("SemVerConverter")] - [SemanticVersion]$Version = $null -} - -# Parse YAML with custom tag -$yaml = "version: !semver '2.1.5-beta3'" -$release = $yaml | ConvertFrom-Yaml -As ([AppRelease]) -# $release.Version.Major = 2 -# $release.Version.Minor = 1 -# $release.Version.Patch = 5 -# $release.Version.PreRelease = "beta3" -``` - -## Class Files - -All class definitions are located in the `classes/` subdirectory and can be dot-sourced as needed. - -### classes/DemoClasses.ps1 - -Simple configuration classes demonstrating: -- No need for manual `ToDictionary`/`FromDictionary` - handled automatically -- Nested `YamlBase` objects -- Arrays - -```powershell -class DatabaseConfig : YamlBase { - [string]$Host = 'localhost' - [int]$Port = 5432 - # ... -} - -class AppConfig : YamlBase { - [string]$AppName = '' - [DatabaseConfig]$Database = $null - [string[]]$AllowedOrigins = @() - # ... -} -``` - -### classes/ServerConfig.ps1 - -Demonstrates `[YamlKey]` attribute: - -```powershell -class ServerConfig : YamlBase { - [YamlKey("Host")] - [string]$PrimaryHost = "" - - [YamlKey("host")] - [string]$BackupHost = "" -} -``` - -### classes/DuplicateKeyClasses.ps1 - -Demonstrates duplicate key detection and prevention: - -```powershell -# Without YamlKey - will fail with duplicate keys -class ConfigWithoutMapping : YamlBase { - [string]$test = "" -} - -# With explicit YamlKey - succeeds with duplicate keys -class ConfigWithMapping : YamlBase { - [YamlKey("test")] - [string]$LowercaseValue = "" - - [YamlKey("Test")] - [string]$CapitalizedValue = "" -} - -# Three case variations -class ThreeVariations : YamlBase { - [YamlKey("test")] - [string]$Lower = "" - - [YamlKey("Test")] - [string]$Capital = "" - - [YamlKey("TEST")] - [string]$Upper = "" -} -``` - -### classes/AdvancedConfig.ps1 - -Complex configuration with all features: - -```powershell -class ServerEndpoint : YamlBase { - [YamlKey("HTTP")] - [string]$HttpUrl = "" - - [YamlKey("HTTPS")] - [string]$HttpsUrl = "" -} - -class AdvancedConfig : YamlBase { - [ServerEndpoint]$PrimaryEndpoint = $null - [ServerEndpoint[]]$BackupEndpoints = @() - - [YamlKey("max-retry-count")] - [int]$MaxRetries = 3 -} -``` - -### classes/CustomConverters.ps1 - -Custom type converters for application-specific types: - -```powershell -# Custom type -class SemanticVersion { - [int]$Major = 0 - [int]$Minor = 0 - [int]$Patch = 0 - [string]$PreRelease = "" - - [string] ToString() { - $ver = "$($this.Major).$($this.Minor).$($this.Patch)" - if ($this.PreRelease) { $ver += "-$($this.PreRelease)" } - return $ver - } -} - -# Converter for SemanticVersion with !semver tag -class SemVerConverter : YamlConverter { - [bool] CanHandle([string]$tag, [Type]$targetType) { - return $targetType -eq [SemanticVersion] - } - - [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { - # Supports both string ("1.2.3-beta") and dictionary formats - # Returns SemanticVersion instance - } - - [object] ConvertToYaml([object]$value) { - return @{ Value = $value.ToString(); Tag = '!semver' } - } -} - -# Usage in a YamlBase class -class AppRelease : YamlBase { - [YamlConverter("SemVerConverter")] - [SemanticVersion]$Version = $null - - [YamlConverter("CustomDateTimeConverter")] - [DateTime]$ReleaseDate = [DateTime]::MinValue -} -``` - -## Key Features - -### 1. Default Implementations - -No need to write `ToDictionary` or `FromDictionary` methods: - -```powershell -# Before (manual implementation required): -class OldClass : YamlBase { - [string]$Name - - [Dictionary[string, object]] ToDictionary() { - # Manual implementation... - } - - [void] FromDictionary([Dictionary[string, object]]$data) { - # Manual implementation... - } -} - -# After (automatic): -class NewClass : YamlBase { - [string]$Name # That's it! -} -``` - -### 2. Automatic Property Name Conversion - -PascalCase → hyphenated-case: - -```powershell -class Config : YamlBase { - [string]$AppName # YAML key: app-name - [string]$DatabaseHost # YAML key: database-host - [int]$MaxConnections # YAML key: max-connections -} -``` - -### 3. YamlKey Attribute - -Override automatic conversion or handle case-sensitive keys: - -```powershell -class Config : YamlBase { - [YamlKey("API-Key")] # Exact YAML key - [string]$ApiKey - - [YamlKey("db_host")] # Use underscores instead of hyphens - [string]$DatabaseHost -} -``` - -### 4. Metadata Preservation - -Comments, tags, and styles automatically preserved: - -```powershell -$yaml = @" -# Important setting -port: !!int "8080" -name: 'MyApp' -"@ - -$config = $yaml | ConvertFrom-YamlTyped -As ([Config]) - -# Metadata is automatically captured -$config.GetPropertyComment('Port') # "Important setting" -$config.GetPropertyTag('Port') # "tag:yaml.org,2002:int" -$config.GetPropertyScalarStyle('Name') # "SingleQuoted" - -# Serialize back - metadata preserved! -$newYaml = $config | ConvertTo-YamlTyped -# Output includes comment, tag, and quotes -``` - -## Tips - -1. **Always initialize properties with default values** to avoid nullability issues: - ```powershell - [string]$Name = "" # Good - [string]$Name # May cause issues - ``` - -2. **Use `YamlKey` for case-sensitive or special format keys**: - ```powershell - [YamlKey("UPPER")] - [string]$UpperCase - - [YamlKey("snake_case")] - [string]$SnakeCase - ``` - -3. **Leverage automatic conversion** for standard keys: - ```powershell - [string]$AppName # Auto-converts to app-name - [int]$MaxConnections # Auto-converts to max-connections - ``` - -4. **Nest YamlBase objects freely** - they're handled automatically: - ```powershell - [DatabaseConfig]$Database = $null - [ServerConfig[]]$Servers = @() - ``` - -5. **Access metadata via `GetProperty*` methods**: - ```powershell - $obj.GetPropertyComment('PropName') - $obj.GetPropertyTag('PropName') - $obj.GetPropertyScalarStyle('PropName') - ``` - -## Running All Examples - -```powershell -# Run each example -pwsh -File examples/typed-yaml-demo.ps1 -pwsh -File examples/yamlkey-attribute.ps1 -pwsh -File examples/metadata-demo.ps1 -pwsh -File examples/duplicate-key-detection.ps1 -pwsh -File examples/advanced-features.ps1 -pwsh -File examples/custom-converters.ps1 -``` diff --git a/lib/netstandard2.0/PowerShellYaml.Module.dll b/lib/netstandard2.0/PowerShellYaml.Module.dll index ed6590582328f50c7dbd43905d68c4f1b49d1b35..375ddc3156b87548aa3a5d964970fdc659d4f9b5 100644 GIT binary patch delta 3351 zcmYk83v?9K8OQ&3cHX-QyPNFh1!+P8$&wHWyK52=8$<#KQ7EAm5DA1RRwIujXsoaa zuZAi@G7v08geHmwt&o-02ikBH1p&1!+M3o=DBw9NV0rkUwWQy@GjQCT{mu9Pf8YJ? zoz2{tY5txyf6q3w%KHZW^OLqG21=Qm+IIbv9kCe8Q}$axm6a_OS~FOll-p>nV9V3` zH$r?S5G5!mp9DNcf>|e*mc*w>xk2{4)tH)_F*r4=zrQ~g%z_LAQ<8)A>i{!J)_k(n zlR9KlqFgAaCKRM4Dur@NLV-_R7zWfN#jm8~w^L<;r0RwoRPv{gt*6w}V$6jD33@#( z!49>B=@bsLpzId9Bp3KL1O~&k3Dz zjn=72&T^oEj(bulp4PDB{)g_X=Ra8PNFd)JZ~8P$9tC*G%Naj?;%#)in{<}dGQ1?= zeNry^o}_PH{NV7{<=Ae0D3+Kn5x;Wx=suh_dvpf)_YteqeR?)7l9Mf*>>oHjTnW5F z{smEA?Aj_boRc^u5vL1&D7`^_o{-)p)=3{yeU{WmJjPCwbGvKn2pX`9(o>sz)UEQ= zW^dwF`9{>S^Lh}4xJW#YtHcUs8Y*9kbwfC!^FAkhMFUQBZ8iD5rA=K4YuhqYMpALh)HuxY2T5 zRah6o8H0+A0XX!7dOvQ?;iGwdg%yfq3@A3zu}XNNqctrOVneKDk%g?%Iv06-{A}Yk zoXHbobfzvKb>2~zRGEauj#{E_HzuRiQFGOu#vRz_sC>1@xD!Vms5IB$_-MOwoBJMf9lCF^)v;i$xdBstY*+fs zMP@rDm)p5^tD+)qD#79&0Cex;L3ya5vxcbcWBfZnF#j z5ULGI@|)%^+&qrw+VHZ{XZE1H!d9C3o%uR8PPCPw1>Fa+;C5SWP8sBW2OAxg8O(LR z8^c9grq1yWF%IFXQ1+fU1eNaCz)D0BavwsXqrOG4`!Irzx|5Z;-$SNQ_P8S$>g0kL z<32(i7!s?@aOR0+zq`5$&27ac;bFWjjbKcyV)ZQ+#B$$M$d;}yR0-ryrzlk_HAq46_n zSG5b$tmIB>@vSMbD4jN+{teE~Rf18n2usfNc527O&1F`3N{HY6KoM&P1Lbg)b9}SvlQoH7y=1J^i)~qEc=z9m4Qqj%O@pVTV2zCb34uHBMZEhdnKLfc%xh zUn%^RsKGW*mvFj-(?!lf&kn}>91uJy$RzG$3yu+-E7&E)2BX%~CHZmIb3j;c37!-@ zB{Ve89^brRF!kB;xl@@C8&_^Sc` z360ylF8^%hpYORLK7YYBIT+AX|84%3sDHMv&fiiN@HKzJe0A1KI z$2ZhnmE9)PM1Q3u>5>%RyyRP!A1$t(7bz`{%quAg-y5kZDJ>friAKxr9T6#u%r6@e z4o6B!)s$JkZ+K@UP9D2_46JgBMC+qBaCiycrPmhA@BX0a=)IM`-IVLLUTn~?X-nx<1mK|jM z@jXk^Sv>KP+g*1?>6TNAd5EBtq6kGL^cTjx6xUExiZYC(I7)wI_J4PJ(wzVR delta 3262 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 hyphenated-case) +* Added -Depth flag to ConvertTo-Yaml +* Added -EmitTags to ConvertTo-Yaml +* Added option to emit yaml using explicit block style (override the round-trip style from the document) Usage: # PSCustomObject mode with metadata @@ -52,6 +55,13 @@ Usage: class MyConfig : YamlBase { [string]$Name } $config = $yaml | ConvertFrom-Yaml -As ([MyConfig]) + # Convert flow style doc to block style doc + $yaml = '{hello: world, goodbye: world}' + ConvertFrom-Yaml $yaml -As ([pscustomobject]) | ConvertTo-Yaml -Options UseBlockStyle + hello: world + goodbye: world + + See examples/ for detailed usage patterns. # 0.4.12 diff --git a/powershell-yaml.psm1 b/powershell-yaml.psm1 index 6ee2512..8884968 100644 --- a/powershell-yaml.psm1 +++ b/powershell-yaml.psm1 @@ -612,12 +612,16 @@ function ConvertTo-Yaml { } elseif ($script:PSObjectMetadataExtensions::IsEnhancedPSCustomObject($d)) { # Use metadata-aware serializer $MetadataAwareSerializer = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.MetadataAwareSerializer') - # Extract indentedSequences option if using Options parameter + # Extract style options if using Options parameter $indentedSequences = $false + $useFlowStyle = $false + $useBlockStyle = $false if ($PSCmdlet.ParameterSetName -eq 'Options') { $indentedSequences = $Options.HasFlag([SerializationOptions]::WithIndentedSequences) + $useFlowStyle = $Options.HasFlag([SerializationOptions]::UseFlowStyle) + $useBlockStyle = $Options.HasFlag([SerializationOptions]::UseBlockStyle) } - $yaml = $MetadataAwareSerializer::Serialize($d, $indentedSequences, $EmitTags.IsPresent, $Depth) + $yaml = $MetadataAwareSerializer::Serialize($d, $indentedSequences, $EmitTags.IsPresent, $Depth, $useFlowStyle, $useBlockStyle) } else { $wrt = New-Object 'System.IO.StringWriter' $norm = Convert-PSObjectToGenericObject $d diff --git a/src/PowerShellYaml.Module/LegacySerializers.cs b/src/PowerShellYaml.Module/LegacySerializers.cs index c6eee13..0a92170 100644 --- a/src/PowerShellYaml.Module/LegacySerializers.cs +++ b/src/PowerShellYaml.Module/LegacySerializers.cs @@ -926,7 +926,7 @@ private static string GetTagFromType(object value) { }; } - public static string Serialize(PSObject obj, bool indentedSequences = false, bool emitTags = false, int maxDepth = 100) { + public static string Serialize(PSObject obj, bool indentedSequences = false, bool emitTags = false, int maxDepth = 100, bool useFlowStyle = false, bool useBlockStyle = false) { var metadata = PSObjectMetadataExtensions.GetMetadata(obj); if (metadata == null) { throw new InvalidOperationException("Object does not have YAML metadata"); @@ -954,7 +954,18 @@ public static string Serialize(PSObject obj, bool indentedSequences = false, boo emitter.Emit(new StreamStart()); emitter.Emit(new DocumentStart()); - SerializePSObject(obj, metadata, emitter, MappingStyle.Block, emitTags, 0, maxDepth); + // Determine mapping style: explicit options override metadata + MappingStyle mappingStyle; + if (useFlowStyle) { + mappingStyle = MappingStyle.Flow; + } else if (useBlockStyle) { + mappingStyle = MappingStyle.Block; + } else { + // Use the original mapping style from metadata, or default to Block + mappingStyle = metadata.DocumentMappingStyle ?? MappingStyle.Block; + } + + SerializePSObject(obj, metadata, emitter, mappingStyle, emitTags, 0, maxDepth); emitter.Emit(new DocumentEnd(true)); emitter.Emit(new StreamEnd());