feat(env): add nestedUnderJQPath for extracting nested subtrees by jq-style paths

Introduce `nestedUnderJQPath`, enabling subtree extraction using jq-style path parsing. Add associated utility functions (`splitJQPath`, `peelRedundantPathLeaf`, `buildNestedTreeAtPath`) and test coverage. Extend `.env Codec` to support path-specific subtree merges. Update `WithFile` to accept an optional jqPath for targeted merges.
This commit is contained in:
2026-05-12 13:54:27 +01:00
parent 4beb769a2b
commit 719fccf712
5 changed files with 258 additions and 20 deletions
+28 -12
View File
@@ -17,9 +17,14 @@ import (
"github.com/joho/godotenv"
)
type loadedLayer struct {
load func(context.Context) (map[string]string, error)
jqPath string // empty: merge full nested tree; e.g. ".service" merges only that subtree
}
// Codec loads environment-style key/value data from multiple sources.
type Codec struct {
layers []func(context.Context) (map[string]string, error)
layers []loadedLayer
prefix string
normalizer keymap.Normalizer
sliceStrat merge.SliceStrategy
@@ -55,11 +60,15 @@ func (c *Codec) Map(ctx context.Context) (map[string]any, error) {
}
acc := make(map[string]any)
for _, layer := range c.layers {
flat, err := layer(ctx)
flat, err := layer.load(ctx)
if err != nil {
return nil, err
}
nested := nestedFromFlat(flat, c.prefix)
if segs := splitJQPath(layer.jqPath); len(segs) > 0 {
sub := nestedUnderJQPath(nested, layer.jqPath)
nested = buildNestedTreeAtPath(segs, sub)
}
merge.DeepMerge(acc, nested, c.mergeOpts...)
}
if c.normalizer != nil {
@@ -156,17 +165,24 @@ func flatFromEnviron(environ []string) map[string]string {
}
func withSource(s source.Source, label string) func(*Codec) {
return withSourceAtJQ(s, label, "")
}
func withSourceAtJQ(s source.Source, label, jqPath string) func(*Codec) {
return func(c *Codec) {
c.layers = append(c.layers, func(ctx context.Context) (map[string]string, error) {
b, err := bytesutil.ReadAll(ctx, s)
if err != nil {
return nil, fmt.Errorf("env: read %s: %w", label, err)
}
m, err := godotenv.UnmarshalBytes(b)
if err != nil {
return nil, fmt.Errorf("env: parse %s: %w", label, err)
}
return m, nil
c.layers = append(c.layers, loadedLayer{
jqPath: jqPath,
load: func(ctx context.Context) (map[string]string, error) {
b, err := bytesutil.ReadAll(ctx, s)
if err != nil {
return nil, fmt.Errorf("env: read %s: %w", label, err)
}
m, err := godotenv.UnmarshalBytes(b)
if err != nil {
return nil, fmt.Errorf("env: parse %s: %w", label, err)
}
return m, nil
},
})
}
}
+109
View File
@@ -0,0 +1,109 @@
package env
import (
"strings"
)
// nestedUnderJQPath returns the value at jqPath inside nested as a sub-tree
// (just the inner fields — no enclosing key matching the path leaf). It
// implements a tiny subset of jq path selection: dotted segments matched on
// lowercased keys, consistent with nestedFromFlat / insertPath.
//
// Examples (jqPath = ".service"):
//
// {"service": {"name": "x", "db": {...}}} → {"name": "x", "db": {...}}
// {"service": {"service": {"name": "inner"}}} → {"name": "inner"} (redundant
// single-key wrapper repeating
// the path leaf is peeled)
// {"other": "v"} → {} (missing)
// {"service": "scalar"} → {} (scalar at
// single-segment path is ignored)
//
// jqPath uses "." segments; a leading "." is optional. Empty / "." returns
// the input map untouched. The caller is responsible for re-wrapping the
// result at jqPath if it wants to merge the sub-tree at the same path in a
// larger document (see Codec.Map).
func nestedUnderJQPath(nested map[string]any, jqPath string) map[string]any {
segs := splitJQPath(jqPath)
if len(segs) == 0 {
return nested
}
var cur any = nested
for _, seg := range segs {
m, ok := cur.(map[string]any)
if !ok {
return map[string]any{}
}
cur = m[seg]
if cur == nil {
return map[string]any{}
}
}
leaf := segs[len(segs)-1]
switch v := cur.(type) {
case map[string]any:
return peelRedundantPathLeaf(v, leaf)
default:
if len(segs) == 1 {
return map[string]any{}
}
return buildNestedTreeAtPath(segs, cur)
}
}
// splitJQPath parses a jq-style dotted path into lowercased segments. Empty
// path or "." yields a nil slice (interpreted as "no path").
func splitJQPath(jqPath string) []string {
jqPath = strings.TrimSpace(jqPath)
if jqPath == "" || jqPath == "." {
return nil
}
jqPath = strings.TrimPrefix(jqPath, ".")
if jqPath == "" {
return nil
}
parts := strings.Split(jqPath, ".")
segs := make([]string, 0, len(parts))
for _, s := range parts {
s = strings.TrimSpace(s)
if s == "" {
continue
}
segs = append(segs, strings.ToLower(s))
}
if len(segs) == 0 {
return nil
}
return segs
}
// peelRedundantPathLeaf strips outer {"<leaf>": {...}} shells while the only
// key in the current map matches pathLeaf. Stops as soon as the map has more
// than one key or the single key differs from pathLeaf.
func peelRedundantPathLeaf(m map[string]any, pathLeaf string) map[string]any {
for len(m) == 1 {
inner, ok := m[pathLeaf].(map[string]any)
if !ok {
break
}
m = inner
}
return m
}
// buildNestedTreeAtPath wraps leaf inside a chain of single-key maps following
// segments, e.g. (["a","b"], 1) → {"a": {"b": 1}}.
func buildNestedTreeAtPath(segments []string, leaf any) map[string]any {
root := make(map[string]any)
cur := root
for i, seg := range segments {
if i == len(segments)-1 {
cur[seg] = leaf
break
}
next := make(map[string]any)
cur[seg] = next
cur = next
}
return root
}
+69
View File
@@ -0,0 +1,69 @@
package env
import (
"reflect"
"testing"
)
func TestNestedUnderJQPath(t *testing.T) {
nested := map[string]any{
"noise": map[string]any{"x": "ignore"},
"service": map[string]any{
"name": "my-service",
"database": map[string]any{
"url": "postgres://db",
},
},
}
got := nestedUnderJQPath(nested, ".service")
want := map[string]any{
"name": "my-service",
"database": map[string]any{
"url": "postgres://db",
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v want %#v", got, want)
}
}
func TestNestedUnderJQPath_EmptyPathNoop(t *testing.T) {
n := map[string]any{"a": "1"}
if got := nestedUnderJQPath(n, ""); !reflect.DeepEqual(got, n) {
t.Fatalf("%#v", got)
}
if got := nestedUnderJQPath(n, "."); !reflect.DeepEqual(got, n) {
t.Fatalf("%#v", got)
}
}
func TestNestedUnderJQPath_MissingPath(t *testing.T) {
got := nestedUnderJQPath(map[string]any{"other": "v"}, ".service")
if len(got) != 0 {
t.Fatalf("got %#v", got)
}
}
func TestNestedUnderJQPath_PeelsRedundantServiceWrapper(t *testing.T) {
nested := map[string]any{
"service": map[string]any{
"service": map[string]any{
"name": "inner",
},
},
}
got := nestedUnderJQPath(nested, ".service")
want := map[string]any{
"name": "inner",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v want %#v", got, want)
}
}
func TestNestedUnderJQPath_ScalarAtServiceIgnored(t *testing.T) {
got := nestedUnderJQPath(map[string]any{"service": "x"}, ".service")
if len(got) != 0 {
t.Fatalf("got %#v", got)
}
}
+16 -5
View File
@@ -18,15 +18,26 @@ type Option func(*Codec)
// WithCurrentEnvironment appends the process environment as a source (read at Map time).
func WithCurrentEnvironment() Option {
return func(c *Codec) {
c.layers = append(c.layers, func(_ context.Context) (map[string]string, error) {
return flatFromEnviron(os.Environ()), nil
c.layers = append(c.layers, loadedLayer{
load: func(_ context.Context) (map[string]string, error) {
return flatFromEnviron(os.Environ()), nil
},
})
}
}
// WithFile appends a dotenv file path as a source.
func WithFile(path string) Option {
return withSource(source.File{Path: path}, path)
// Optional jqPath selects only that subtree before merging (jq-style path, e.g. ".service"):
// only the object at that path is merged into the config at the same path (its fields,
// not a scalar binding for the whole branch). Keys outside that path in the file are
// ignored. A redundant single-key wrapper repeating the path leaf (e.g. service.service.*)
// is flattened so fields merge directly under service.
func WithFile(path string, jqPath ...string) Option {
jp := ""
if len(jqPath) > 0 {
jp = jqPath[0]
}
return withSourceAtJQ(source.File{Path: path}, path, jp)
}
// WithBytes appends raw dotenv bytes as a source.
@@ -77,7 +88,7 @@ func WithKeyNormalizer(n keymap.Normalizer) Option {
return func(c *Codec) { c.normalizer = n }
}
// WithSliceMerge sets slice merge strategy when merging sources.
// WithSliceMerge sets a slice merge strategy when merging sources.
func WithSliceMerge(s merge.SliceStrategy) Option {
return func(c *Codec) { c.sliceStrat = s }
}