Repository: compose-spec/compose-go
Pull request #700 (2024-10-17 08:10:33)

introduce OmitEmpty in yaml processing pipeline
Introduce a generic mechanism to drop attributes with an empty value. A typical scenario is: attribute is set by an interpolation variable, which resolves to empty string (maybe variable is unset? Then other already got a warning). Some attribute are just irrelevant with an empty value, so need to be ignored. Better just remove those from model

This first iteration drops empty `dns` as requested on https://github.com/docker/compose/issues/11690
This relies on the path pattern syntax used in other yaml processing stages, so we can easily add more "omit" rules.

closes https://github.com/docker/compose/issues/11690


Patch:
diff --git a/loader/loader.go b/loader/loader.go
index 26e9d4d..62030f2 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -516,6 +516,8 @@ func loadYamlFile(ctx context.Context, file types.ConfigFile, opts *Options, wor
 			return err
 		}
 
+		dict = OmitEmpty(dict)
+
 		// Canonical transformation can reveal duplicates, typically as ports can be a range and conflict with an override
 		dict, err = override.EnforceUnicity(dict)
 		return err
diff --git a/loader/normalize.go b/loader/normalize.go
index 6e7fb71..bae70be 100644
--- a/loader/normalize.go
+++ b/loader/normalize.go
@@ -135,9 +135,9 @@ func Normalize(dict map[string]any, env types.Mapping) (map[string]any, error) {
 			}
 			services[name] = service
 		}
+
 		dict["services"] = services
 	}
-
 	setNameFromKey(dict)
 
 	return dict, nil
diff --git a/loader/omitEmpty.go b/loader/omitEmpty.go
new file mode 100644
index 0000000..bc1cb1a
--- /dev/null
+++ b/loader/omitEmpty.go
@@ -0,0 +1,74 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   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.
+*/
+
+package loader
+
+import "github.com/compose-spec/compose-go/v2/tree"
+
+var omitempty = []tree.Path{
+	"services.*.dns"}
+
+// OmitEmpty removes empty attributes which are irrelevant when unset
+func OmitEmpty(yaml map[string]any) map[string]any {
+	cleaned := omitEmpty(yaml, tree.NewPath())
+	return cleaned.(map[string]any)
+}
+
+func omitEmpty(data any, p tree.Path) any {
+	switch v := data.(type) {
+	case map[string]any:
+		for k, e := range v {
+			if isEmpty(e) && mustOmit(p) {
+				delete(v, k)
+				continue
+			}
+
+			v[k] = omitEmpty(e, p.Next(k))
+		}
+		return v
+	case []any:
+		var c []any
+		for _, e := range v {
+			if isEmpty(e) && mustOmit(p) {
+				continue
+			}
+
+			c = append(c, omitEmpty(e, p.Next("[]")))
+		}
+		return c
+	default:
+		return data
+	}
+}
+
+func mustOmit(p tree.Path) bool {
+	for _, pattern := range omitempty {
+		if p.Matches(pattern) {
+			return true
+		}
+	}
+	return false
+}
+
+func isEmpty(e any) bool {
+	if e == nil {
+		return true
+	}
+	if v, ok := e.(string); ok && v == "" {
+		return true
+	}
+	return false
+}
diff --git a/transform/canonical.go b/transform/canonical.go
index a248b4b..ff5bb37 100644
--- a/transform/canonical.go
+++ b/transform/canonical.go
@@ -33,6 +33,7 @@ func init() {
 	transformers["services.*.extends"] = transformExtends
 	transformers["services.*.networks"] = transformServiceNetworks
 	transformers["services.*.volumes.*"] = transformVolumeMount
+	transformers["services.*.dns"] = transformStringOrList
 	transformers["services.*.devices.*"] = transformDeviceMapping
 	transformers["services.*.secrets.*"] = transformFileMount
 	transformers["services.*.configs.*"] = transformFileMount
@@ -48,6 +49,15 @@ func init() {
 	transformers["include.*"] = transformInclude
 }
 
+func transformStringOrList(data any, _ tree.Path, _ bool) (any, error) {
+	switch t := data.(type) {
+	case string:
+		return []any{t}, nil
+	default:
+		return data, nil
+	}
+}
+
 // Canonical transforms a compose model into canonical syntax
 func Canonical(yaml map[string]any, ignoreParseError bool) (map[string]any, error) {
 	canonical, err := transform(yaml, tree.NewPath(), ignoreParseError)


Test patch:
diff --git a/loader/loader_test.go b/loader/loader_test.go
index ec68686..5318636 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -3581,3 +3581,14 @@ services:
 		},
 	})
 }
+
+func TestOmitEmptyDNS(t *testing.T) {
+	p, err := loadYAML(`
+name: load-empty-dsn
+services:
+  test:
+    dns: ${UNSET_VAR}
+`)
+	assert.NilError(t, err)
+	assert.Equal(t, len(p.Services["test"].DNS), 0)
+}