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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ $doc.Data['version'] = 2
Export-Toml -InputObject $doc -Path './config.toml'
```

### Merge two TOML documents

```powershell
$defaults = @'
[server]
host = "localhost"
port = 8080
'@

$overrides = @'
[server]
port = 9090
'@

Merge-Toml -BaseObject $defaults -OverrideObject $overrides
# [server]
# host = "localhost"
# port = 9090
```

`Merge-Toml` also accepts `-Path`/`-LiteralPath` for merging files, and a `-Strategy` of `LastWins` (default), `FirstWins`, or `ErrorOnConflict` for resolving scalar key conflicts. Nested tables are always deep-merged and arrays of tables are always concatenated.

## TOML type mapping

| TOML type | PowerShell type |
Expand All @@ -102,6 +124,7 @@ Export-Toml -InputObject $doc -Path './config.toml'
| `ConvertTo-Toml` | Serialize object β†’ TOML text |
| `Import-Toml` | Read TOML file β†’ `TomlDocument` |
| `Export-Toml` | Write object or `TomlDocument` to file |
| `Merge-Toml` | Merge two TOML documents into one |

## Implementation notes

Expand Down
101 changes: 101 additions & 0 deletions src/functions/private/Merge-TomlTableObject.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
function Merge-TomlTableObject {
<#
.SYNOPSIS
Recursively merges two TOML table dictionaries.

.DESCRIPTION
Walks every key in the override dictionary and combines it with the base
dictionary. Keys missing from the base are added as-is. Nested tables
(OrderedDictionary) are merged recursively. Arrays of tables (ArrayList of
OrderedDictionary) are concatenated, base entries first. Any other
overlapping key β€” scalar or inline array β€” is resolved with the given
merge strategy: LastWins keeps the override value, FirstWins keeps the
base value, and ErrorOnConflict throws.

.EXAMPLE
$base = [ordered]@{ a = 1; server = [ordered]@{ host = 'localhost' } }
$override = [ordered]@{ b = 2; server = [ordered]@{ port = 80 } }
Merge-TomlTableObject -Base $base -Override $override -Strategy 'LastWins'
# Returns: OrderedDictionary { a = 1, server = { host = 'localhost', port = 80 }, b = 2 }

Deep-merges a nested table while preserving keys unique to each side.

.EXAMPLE
$base = [ordered]@{ key = 'base' }
$override = [ordered]@{ key = 'override' }
Merge-TomlTableObject -Base $base -Override $override -Strategy 'ErrorOnConflict'
# Throws because 'key' is defined on both sides.

Demonstrates conflict detection for duplicate scalar keys.

.INPUTS
None. Parameters only.

.OUTPUTS
[System.Collections.Specialized.OrderedDictionary]
#>
[OutputType([System.Collections.Specialized.OrderedDictionary])]
[CmdletBinding()]
param(
# The base table. Its keys are preserved unless overridden.
[Parameter(Mandatory)]
[System.Collections.Specialized.OrderedDictionary] $Base,

# The override table applied on top of the base.
[Parameter(Mandatory)]
[System.Collections.Specialized.OrderedDictionary] $Override,

# The strategy used to resolve scalar key conflicts.
[Parameter(Mandatory)]
[ValidateSet('LastWins', 'FirstWins', 'ErrorOnConflict')]
[string] $Strategy
)

$result = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal)
foreach ($key in $Base.Keys) {
$result[$key] = $Base[$key]
}

foreach ($key in $Override.Keys) {
if (-not $result.Contains($key)) {
Write-Verbose "Adding override-only key '$key'."
$result[$key] = $Override[$key]
continue
}

$baseValue = $result[$key]
$overrideValue = $Override[$key]

if ($baseValue -is [System.Collections.Specialized.OrderedDictionary] -and
$overrideValue -is [System.Collections.Specialized.OrderedDictionary]) {
Write-Verbose "Deep-merging nested table for key '$key'."
$result[$key] = Merge-TomlTableObject -Base $baseValue -Override $overrideValue -Strategy $Strategy
continue
}

if ($baseValue -is [System.Collections.ArrayList] -and $overrideValue -is [System.Collections.ArrayList]) {
Write-Verbose "Concatenating array-of-tables for key '$key'."
$combined = [System.Collections.ArrayList]::new($baseValue)
$null = $combined.AddRange($overrideValue)
$result[$key] = $combined
continue
}

switch ($Strategy) {
'LastWins' {
Write-Verbose "Resolving scalar conflict for key '$key' using LastWins."
$result[$key] = $overrideValue
}
'FirstWins' {
Write-Verbose "Resolving scalar conflict for key '$key' using FirstWins."
}
'ErrorOnConflict' {
throw [System.InvalidOperationException]::new(
"The key '$key' is defined in both documents and the merge strategy is 'ErrorOnConflict'."
)
}
}
}

return $result
}
111 changes: 111 additions & 0 deletions src/functions/public/Merge-Toml.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
function Merge-Toml {
<#
.SYNOPSIS
Merges two or more TOML documents into one.

.DESCRIPTION
Parses TOML documents with ConvertFrom-Toml, deep-merges the resulting
ordered dictionaries with the private Merge-TomlTableObject helper, and
serializes the combined result back to TOML text with ConvertTo-Toml.

Nested tables are always merged recursively. Arrays of tables are always
concatenated, base entries first, then override entries. Scalar key
conflicts (including inline arrays, which are treated as scalars) are
resolved with -Strategy:
- LastWins (default): the override value replaces the base value
- FirstWins: the base value is kept and the override value is ignored
- ErrorOnConflict: a duplicate scalar key throws

When -Path or -LiteralPath is given with more than two files, documents
are merged left to right: the first file is the base, and each
subsequent file is merged on top of the accumulated result in order.

.EXAMPLE
Merge-Toml -BaseObject 'a = 1' -OverrideObject 'b = 2'
# Returns: "a = 1`nb = 2"

Merges two TOML strings with no overlapping keys.

.EXAMPLE
Merge-Toml -Path 'defaults.toml', 'local.toml' -Strategy 'FirstWins'

Merges two files in order, keeping the base value whenever both files
define the same scalar key.

.INPUTS
None. Parameters only.

.OUTPUTS
[string]

.NOTES
Throws when a file cannot be found or read, when either document is not
valid TOML, or when -Strategy 'ErrorOnConflict' encounters a duplicate
scalar key.

.LINK
https://psmodule.io/Toml/Functions/Merge-Toml
#>
[OutputType([string])]
[CmdletBinding(DefaultParameterSetName = 'Default')]
param(
# The base TOML document, provided as a string.
[Parameter(Mandatory, ParameterSetName = 'Default')]
[ValidateNotNullOrEmpty()]
[string] $BaseObject,

# The override TOML document applied on top of the base document.
[Parameter(Mandatory, ParameterSetName = 'Default')]
[ValidateNotNullOrEmpty()]
[string] $OverrideObject,

# File paths to merge in order. The first path is the base document,
# and each subsequent path is merged on top of the accumulated result.
[Parameter(Mandatory, ParameterSetName = 'Path')]
[ValidateCount(2, [int]::MaxValue)]
[string[]] $Path,

# Literal file paths to merge in order, without wildcard expansion.
# The first path is the base document, and each subsequent path is
# merged on top of the accumulated result.
[Parameter(Mandatory, ParameterSetName = 'LiteralPath')]
[ValidateCount(2, [int]::MaxValue)]
[string[]] $LiteralPath,

# The strategy used to resolve scalar key conflicts between documents.
[Parameter()]
[ValidateSet('LastWins', 'FirstWins', 'ErrorOnConflict')]
[string] $Strategy = 'LastWins'
)

process {
$documents = switch ($PSCmdlet.ParameterSetName) {
'Default' {
@($BaseObject, $OverrideObject)
}
'Path' {
Write-Verbose "Reading $($Path.Count) file(s) for merge."
foreach ($p in $Path) {
$resolvedPath = Resolve-Path -Path $p -ErrorAction Stop
[System.IO.File]::ReadAllText($resolvedPath.ProviderPath)
}
}
'LiteralPath' {
Write-Verbose "Reading $($LiteralPath.Count) file(s) for merge."
foreach ($p in $LiteralPath) {
$resolvedPath = Resolve-Path -LiteralPath $p -ErrorAction Stop
[System.IO.File]::ReadAllText($resolvedPath.ProviderPath)
}
}
}

$merged = (ConvertFrom-Toml -InputObject $documents[0]).Data
for ($i = 1; $i -lt $documents.Count; $i++) {
$override = (ConvertFrom-Toml -InputObject $documents[$i]).Data
Write-Verbose "Merging document $($i + 1) of $($documents.Count) using strategy '$Strategy'."
$merged = Merge-TomlTableObject -Base $merged -Override $override -Strategy $Strategy
}

return ConvertTo-Toml -InputObject $merged
}
}
Loading