Skip to content

feat: Add support for key+ syntax #2088

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

nocturo
Copy link

@nocturo nocturo commented Jun 25, 2025

This PR adds support for key+ syntax that is used in several other places for appending to lists and maps instead of overwriting them and keeping your code DRY (in case you want to append to some map you've defined before).

How would you use it?

helmfile.yaml:

repositories:
  - name: bitnami
    url: https://charts.bitnami.com/bitnami

releases:
  - name: myapp
    namespace: default
    chart: bitnami/nginx
    version: 15.0.0
    values:
      - base.yaml
      - override.yaml

base.yaml:

replicaCount: 2
image:
  tag: "1.21"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80

ingress:
  enabled: false

resources:
  limits:
    cpu: 100m
    memory: 128Mi
  requests:
    cpu: 100m
    memory: 128Mi

labels:
  app: myapp
  environment: dev

override.yaml:

replicaCount+: 3
image+:
  tag: "1.25"

service+:
  type: LoadBalancer
  port: 443

ingress+:
  enabled: true
  hosts:
    - host: myapp.example.com

resources+:
  limits:
    cpu: 200m
    memory: 256Mi

labels+:
  environment: prod
  team: platform

and we get these values out:

image:
  pullPolicy: IfNotPresent
  tag: "1.25"
ingress:
  enabled: true
  hosts:
    - host: myapp.example.com
labels:
  app: myapp
  environment: prod
  team: platform
replicaCount: 3
resources:
  limits:
    cpu: 200m
    memory: 256Mi
  requests:
    cpu: 100m
    memory: 128Mi
service:
  port: 443
  type: LoadBalancer

Let me know if this is something that can benefit the project.

@yxxhero
Copy link
Member

yxxhero commented Jun 25, 2025

please fix DCO

@yxxhero
Copy link
Member

yxxhero commented Jun 25, 2025

@nocturo

@nocturo
Copy link
Author

nocturo commented Jun 26, 2025

Done! @yxxhero

@yxxhero
Copy link
Member

yxxhero commented Jun 26, 2025

@nocturo please fix tests issues.

@yxxhero
Copy link
Member

yxxhero commented Jun 29, 2025

@nocturo still failed.

@z0rc
Copy link

z0rc commented Jul 8, 2025

Please document this behaviour, so users would actually able to discover and use it.

@yxxhero
Copy link
Member

yxxhero commented Jul 10, 2025

@nocturo ping

@nocturo
Copy link
Author

nocturo commented Jul 14, 2025

Hi @yxxhero @z0rc. I've addressed the linter issue and added some documentation about the feature as requested. If it's not a correct place to document it, let me know where would you like to see it so that I can correct it.

@yxxhero
Copy link
Member

yxxhero commented Jul 14, 2025

@nocturo awesome!thanks for your work.

return generatedFiles, nil
}

// mergeAppendValues merges two values for the same key, preserving key+ keys for later processing
func mergeAppendValues(existing, incoming any) any {
Copy link
Member

Choose a reason for hiding this comment

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

could you add unittest for this func?

Copy link
Author

Choose a reason for hiding this comment

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

Added a unit test for it, is it suitable?

Copy link
Member

Choose a reason for hiding this comment

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

yeah

@yxxhero
Copy link
Member

yxxhero commented Jul 14, 2025

@nocturo please fix DCO issue.

@nocturo
Copy link
Author

nocturo commented Jul 15, 2025

@yxxhero fixed DCO

@yxxhero
Copy link
Member

yxxhero commented Jul 15, 2025

@mumoshu WDYT?

Copy link
Contributor

@mumoshu mumoshu left a comment

Choose a reason for hiding this comment

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

Awesome work @nocturo!
Looks great and feeling very close to merge this!
But I have several comments regarding unused and potentially unnecessary functions.
Could you confirm?
If we could remove those unnecessary parts, we could also slim down the additional tests.

Comment on lines 364 to 365
var result map[string]any
err := UnmarshalWithAppend([]byte(tt.yamlData), &result)
Copy link
Contributor

Choose a reason for hiding this comment

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

This should also cover cases when result is non-empty, like you already did (thanks!) for TestAppendProcessor_MergeWithAppend, but for UnmarshalWithAppend this time.

return &AppendProcessor{}
}

func (ap *AppendProcessor) ProcessMap(data map[string]any) (map[string]any, error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't quite understand why ProcessMap is needed!
I think the key idea is the use the new MergeWithAppend instead of mergo.Merge when merging src values to dst values, and MergeWithAppend does not depend on this ProcessMap (and the utility processValue), right?
Could it be possible that you can just remove this ProcessMap and the new merge-with-append feature just work?

@@ -1683,11 +1682,12 @@ func (st *HelmState) WriteReleasesValues(helm helmexec.Interface, additionalValu
return []error{fmt.Errorf("reading %s: %w", f, err)}
}

if err := yaml.Unmarshal(srcBytes, &src); err != nil {
if err := yaml.UnmarshalWithAppend(srcBytes, &src); err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need the special Unmarshal function here? The merge happens on MergeWithAppend, which is aware of the new key+ syntax.
Isnt this special unmarshal function unnecessary, given we could just unmarshal using yaml.Unmarshal so that you get the new src map whose keys may or may not contain the + suffix, which is then merged using optional list-appending feature by the new MergeWithAppend.

pkg/yaml/yaml.go Outdated
return Unmarshal(processedYAML, v)
}

// NewDecoderWithAppend creates and returns a function that is used to decode a YAML document
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like this new NewDecoderWithAppend has no use and that's why this looks unused?
If I'm not mistaken, this could be removed?

pkg/yaml/yaml.go Outdated
@@ -63,3 +63,76 @@ func Unmarshal(data []byte, v any) error {

return v2.Unmarshal(data, v)
}

// UnmarshalWithAppend unmarshals YAML data with support for key+ syntax
Copy link
Contributor

Choose a reason for hiding this comment

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

As commented elsewhere, I feel like this is not necessary and can be removed. Merge-with-list-appending happens elsewhere so you won't need to touch unmarshaler and decoder?

@nocturo
Copy link
Author

nocturo commented Jul 17, 2025

I've simplified the code further, hopefully a much cleaner solution!

@nocturo nocturo requested a review from mumoshu July 17, 2025 08:28
nocturo added 5 commits July 19, 2025 07:53
Signed-off-by: Nemanja Zeljkovic <[email protected]>
Signed-off-by: Nemanja Zeljkovic <[email protected]>
Signed-off-by: Nemanja Zeljkovic <[email protected]>
Signed-off-by: Nemanja Zeljkovic <[email protected]>
@JuryA
Copy link

JuryA commented Jul 19, 2025

Hi, interesting feature—thanks for sharing!

I was just reading the PR description and got a bit confused by the statement that the key+ syntax allows for appending to lists and maps instead of overwriting them. To my knowledge, standard YAML maps don’t really have an “append” operation—unless you’re referring to ordered maps (like !!omap), which aren’t commonly supported in most YAML parsers.

Also, in the examples provided, I don’t actually see any lists being appended—only maps being merged. As far as I can tell, what’s shown in the PR is already default behavior for maps in many YAML tools, so it’s not clear what benefit the + suffix adds in this context.

The overall idea sounds very useful, especially for lists (where an append would be really handy!), but as written, the explanation around maps and lists could be a bit misleading.

If I’m missing something, please let me know—just wanted to flag this for clarity!

Copy link

@JuryA JuryA left a comment

Choose a reason for hiding this comment

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

👎 I Strongly Oppose Introducing a Custom Append Processor

This code duplicates and oversimplifies—therefore misrepresents—functionality that is already better handled by mergo:

  • Redundant Wheel Reinventionmergo.Merge(&dst, src, mergo.WithOverride, mergo.WithAppendSlice) already does this (and much more), including recursive map merging and safe slice appending
  • Incorrect “Append” Semantics – When srcValue or destValue aren't slices, the result is a destructive overwrite (dest[baseKey] = srcValue). This breaks user expectations, as “append” should imply non-destructive extension
  • Panic Risk – The type assertion srcValue.([]any) panics if the slice holds a different type; mergo handles this safely via reflect and error-return logic
  • Maps ≠ Lists – Go maps are explicitly unordered (per the Go spec); “appending” to a map makes no conceptual sense—only key merging is possible, not preserving order
  • Maintainability & Test Coveragemergo is battle-tested (Docker, K8s, Datadog) ; this custom implementation has minimal test coverage and increases bug surface (+95 LOC in this PR)
  • Unnecessary Complexity – Now there are two merge paths (Mergo + AppendProcessor), increasing cognitive overhead and behavioral divergence risk
  • Breaking Change with No Functional Gain – This is a definitive breaking change that offers no meaningful functionality in return. It disrupts expected behavior without justification.

⚠️ Insufficient Tests

The current pkg/yaml/append_processor_test.go includes only four happy-path cases:
append slice→slice, recursion, creating a new slice, and overwriting a non-slice. It fails to cover:

  1. Type Collisions – e.g., []string vs []any; current implementation panics on type assert
  2. Append on Maps or Scalars – silently overwrites; completely untested.
  3. Immutability of srcdelete mutates the input, but tests don’t verify this side effect.
  4. Nil vs Empty Slices – subtle but important; diverges from mergo.WithAppendSlice behavior
  5. Property-Based Edge Cases – would easily reveal issues above; just use gopter or rapid

The result is a false positive CI: the function never returns an error, and tests don't provoke panic conditions—so everything "passes."


💡 Recommendation

  1. Retain only key+ detection, but delegate merging to mergo (WithOverride, WithAppendSlice); for special cases, use a custom mergo.Transformer
  2. Add negative tests (type mismatches, maps, nil slices) and property-based tests.
  3. Eliminate input mutation → return a merged result without side effects.

TL;DR: Instead of 100 lines of risky custom code, use one clean call to mergo.Merge(...)—with proper test coverage. It’s safer, more robust, and fully maintainable.

srcSlice := srcValue.([]any)
dest[baseKey] = append(destSlice, srcSlice...)
} else {
dest[baseKey] = srcValue
Copy link

Choose a reason for hiding this comment

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

Am I seeing this correctly? Is this just a hard overwrite of the value instead of a merge? 🫨

dest[baseKey] = srcValue
}
} else {
dest[baseKey] = srcValue
Copy link

Choose a reason for hiding this comment

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

Is this the same case here as well — a hard overwrite instead of a merge?

@mumoshu
Copy link
Contributor

mumoshu commented Jul 21, 2025

@JuryA Thanks!

The core problem for us is that we don't want a global setting to toggle mergo.WithAppendSlice, nor do we wish to implement one, so if you could improve this to flexibly update the merge options depending on a "marker" like + this strives to introduce, that would definitely be better, yes!

My concern is that I think @nocturo and you have divergent ideas on how to implement it/move this forward?
Would you mind try implementing it in a way you proposed, if you have some time? That way we can compare what works better in practice, before moving on!

Thank you in advance for your support!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants