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
2 changes: 1 addition & 1 deletion docs/env-sync.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Selectors use **hybrid syntax**:
> starting with `@` is treated as complex. Top-level keys whose name
> starts with `@` need to be selected via JMESPath quoted identifiers
> (e.g. `'"@foo"'`) instead of as bare dot-paths.
- **Mixed lists** are supported: simple and complex selectors on the same file are evaluated independently and merged at the top level via the Node API. Simple-side comments and layout survive the merge; complex-side keys carry no comments. JMESPath wins on key collisions.
- **Mixed lists** are supported **for YAML files only**: simple and complex selectors on the same YAML file are evaluated independently and merged at the top level via the Node API. Simple-side comments and layout survive the merge; complex-side keys carry no comments. JMESPath wins on key collisions. JSON files reject mixed selectors with an explicit error — use either dot-paths only or JMESPath only — because JSON has no comments to preserve and the merge logic only existed to keep YAML comments intact.

> **⚠ `items()` returns tuples, not objects in the v1.1.1 Go fork**
>
Expand Down
66 changes: 24 additions & 42 deletions pkg/env/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,16 @@ import (
// layout are NOT preserved for complex expressions. That trade-off is
// only paid when the caller explicitly writes a complex query.
//
// - Mixing simple and complex selectors on the same file is supported:
// simple ones extract via Node API (comments kept), complex ones run
// via JMESPath (comments lost for their output), and the results are
// merged top-level into the final document. Any key present in both
// results has the JMESPath version take precedence (authoritative).
// - Mixing simple and complex selectors on the same file is supported
// **for YAML only**: simple ones extract via Node API (comments
// kept), complex ones run via JMESPath (comments lost for their
// output), and the results are merged top-level into the final
// document. Any key present in both results has the JMESPath
// version take precedence (authoritative). For JSON files,
// mixing simple and complex selectors on the same file is
// explicitly rejected by `filterJSONHybrid` because JSON has no
// comments to preserve and the merge logic only existed to
// keep YAML comments intact.
//
// See docs/env-sync.mdx for examples and the comment-preservation matrix.
func filterContent(content []byte, selectors []string, filePath string) ([]byte, error) {
Expand Down Expand Up @@ -90,11 +95,19 @@ func filterYAMLHybrid(content []byte, selectors []string) ([]byte, error) {
return mergeYAMLTopLevel(simpleOut, jmesOut)
}

// filterJSONHybrid dispatches JSON to either the legacy gjson path or
// JMESPath. Since JSON has no comments, the only benefit of the gjson
// path is its slightly different semantics — but the hybrid classifier
// keeps behavior uniform with YAML so users don't have to learn two
// dialects.
// filterJSONHybrid dispatches JSON selectors to either the legacy gjson
// path or JMESPath based on the classifier. JSON has no comments to
// preserve, so the only reason to keep two paths is the differing
// semantics: gjson handles simple dot-paths and preserves wrapping
// keys, JMESPath handles full expressions.
//
// The mixed case (a single file with both simple AND complex selectors)
// is intentionally unsupported. Mixing them on a JSON file would
// require merging the gjson output and the JMESPath output at the
// top-level, and that merge has no test coverage and no real-world
// use case (users either want a clean dot-path extraction or a full
// JMESPath query, not both on the same JSON file). Erroring out is
// preferable to silently shipping an untested merge path.
Comment thread
fentas marked this conversation as resolved.
func filterJSONHybrid(content []byte, selectors []string) ([]byte, error) {
simple, complex := splitSelectorsByComplexity(selectors)

Expand All @@ -105,16 +118,7 @@ func filterJSONHybrid(content []byte, selectors []string) ([]byte, error) {
return filterJSONJMESPath(content, complex)
}

// Mixed: run both and merge.
simpleOut, err := filterJSON(content, simple)
if err != nil {
return nil, err
}
jmesOut, err := filterJSONJMESPath(content, complex)
if err != nil {
return nil, err
}
return mergeJSONTopLevel(simpleOut, jmesOut)
return nil, fmt.Errorf("mixing simple and complex selectors on a JSON file is not supported — use either dot-paths only or JMESPath only (got %d simple and %d complex selectors)", len(simple), len(complex))
}

// mergeYAMLTopLevel merges b's top-level keys into a's. Any key present
Expand Down Expand Up @@ -200,28 +204,6 @@ func findYAMLTopLevelKey(mapping *yaml.Node, key string) int {
return -1
}

// mergeJSONTopLevel is the JSON sibling of mergeYAMLTopLevel.
func mergeJSONTopLevel(a, b []byte) ([]byte, error) {
var aData, bData map[string]interface{}
if err := json.Unmarshal(a, &aData); err != nil {
return nil, fmt.Errorf("merging JSON results: parsing simple: %w", err)
}
if err := json.Unmarshal(b, &bData); err != nil {
return nil, fmt.Errorf("merging JSON results: parsing jmespath: %w", err)
}
if aData == nil {
aData = make(map[string]interface{})
}
for k, v := range bData {
aData[k] = v
}
out, err := json.MarshalIndent(aData, "", " ")
if err != nil {
return nil, fmt.Errorf("encoding merged JSON: %w", err)
}
return append(out, '\n'), nil
}

// filterYAML extracts selected keys from YAML content using yaml.v3 Node API.
// For whole top-level keys, extracts directly from the AST — preserving comments,
// key ordering, and block/flow style. Nested dot-path selectors use gjson with a
Expand Down
19 changes: 19 additions & 0 deletions pkg/env/select_jmespath_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,25 @@ profiles:
}
}

// TestFilterContent_JSON_MixedSelectorsErrors verifies that mixing
// simple and complex selectors on a JSON file errors out clearly
// instead of going through an untested merge path. JSON has no
// comments to preserve, so the historical "mixed mode" merge in
// filterJSONHybrid was dead code with no real-world use case.
// Routing requests must be either all simple or all complex.
func TestFilterContent_JSON_MixedSelectorsErrors(t *testing.T) {
content := []byte(`{"binaries": {"a": 1}, "envs": {}}`)
_, err := filterContent(content,
[]string{"envs", "{binaries: from_items(items(binaries)[?true])}"},
"config.json")
if err == nil {
t.Fatal("expected error for mixed simple+complex selectors on JSON")
}
if !strings.Contains(err.Error(), "use either dot-paths only or JMESPath only") {
t.Errorf("error should explain selector-mode guidance, got: %v", err)
}
}

func TestFilterContent_JMESPath_JSON(t *testing.T) {
content := []byte(`{"binaries": {"a": {"g": ["x"]}, "b": {"g": ["y"]}}, "envs": {}}`)
out, err := filterContent(content,
Expand Down
Loading