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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.3.0 - 2026-09-09

- Add `CompiledMask#each_path` to enumerate selected paths with decoded field names and a distinct
`JsonMask::WILDCARD` token. Whole-field selections absorb descendants, and terminal wildcards
absorb sibling selections.
- Add `JsonMask.format_path` to turn a path or prefix into an escaped slash-separated selector.

## 0.2.0 - 2026-09-04

- Expose compiled selectors for inspection. `CompiledMask#selection_tree` returns the root
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,48 @@ produce one by inspecting the compiled selector.

## Inspecting a compiled selector

### Selected paths

`CompiledMask#each_path` yields a fresh array of segments for each selected path. Without a block,
it returns an `Enumerator`:

```ruby
mask = JsonMask.compile("id,assets(url,width),*/name")

mask.each_path.to_a
# => [["id"], ["assets", "url"], ["assets", "width"], [JsonMask::WILDCARD, "name"]]

mask.each_path { |path| puts JsonMask.format_path(path) }
# id
# assets/url
# assets/width
# */name
```

Named segments are unescaped strings. `JsonMask::WILDCARD` (the symbol `:*`) represents a wildcard;
the string `"*"` represents a literal field named `*`.

Paths follow the compiled tree: named fields are visited depth first, then the wildcard at each
level. Repeated named selections are merged. Selecting a whole field absorbs its descendants,
and a terminal wildcard absorbs its siblings: `id,id/extra` yields only `["id"]`, while `*,typo`
yields only `[JsonMask::WILDCARD]`. Nested wildcard paths stay separate from named paths.
Blank selectors yield no paths. With a block, `each_path` returns the compiled mask.

`JsonMask.format_path` converts a yielded path, or a prefix of one, into an escaped slash-separated
selector. It escapes structural characters, whitespace, and NUL so literal names survive parsing:

```ruby
JsonMask.format_path(["a/b", "name"]) # => "a\\/b/name"
JsonMask.format_path(["*", "id"]) # => "\\*/id"
JsonMask.format_path([JsonMask::WILDCARD, "id"]) # => "*/id"
JsonMask.format_path([]) # => ""
```

This lets an application inspect or validate paths without traversing the selection tree or
handling selector escaping itself.

### Selection tree

`JsonMask::CompiledMask#selection_tree` exposes the parsed selector as an immutable tree, for
callers that need to examine a selector rather than apply it:

Expand Down
12 changes: 12 additions & 0 deletions lib/json_mask.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@
require_relative 'json_mask/compiled_mask'

module JsonMask
WILDCARD = :*

class << self
def call(value, fields, **options)
compile(fields, **options).call(value)
end

alias mask call

# Formats a path from CompiledMask#each_path, or a prefix of one, as a selector.
#
# @param path [Array<String, Symbol>] unescaped field names and WILDCARD segments
# @return [String] a slash-separated selector with literal names escaped
def format_path(path)
path.map do |name|
name == WILDCARD ? '*' : name.gsub(%r{[\\,/()*\s\x00]}) { |character| "\\#{character}" }
end.join('/')
end

def compile(
fields,
max_length: Parser::DEFAULT_MAX_LENGTH,
Expand Down
29 changes: 29 additions & 0 deletions lib/json_mask/compiled_mask.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,34 @@ def call(value)
end

alias filter call

# Yields selected paths with unescaped names and JsonMask::WILDCARD segments.
# Whole-field selections absorb descendants; a terminal wildcard absorbs siblings.
#
# @yieldparam path [Array<String, Symbol>] a fresh array for each selected path
# @return [Enumerator, CompiledMask] an enumerator without a block; self with a block
def each_path(&block)
return enum_for(__method__) unless block

walk_paths(selection_tree, [], &block) if selection_tree
self
end

private

def walk_paths(tree, prefix, &block)
return yield(prefix + [WILDCARD]) if tree.wildcard&.leaf?

selections = tree.named.to_a
selections << [WILDCARD, tree.wildcard] if tree.wildcard
selections.each do |name, selection|
path = prefix + [name]
if selection.leaf?
yield path
else
walk_paths(selection.children, path, &block)
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/json_mask/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module JsonMask
VERSION = '0.2.0'
VERSION = '0.3.0'
end
93 changes: 93 additions & 0 deletions test/json_mask_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,99 @@ def test_mask_is_an_alias_for_call
assert_equal({ 'id' => 1 }, JsonMask.mask(input, 'id'))
end

def test_each_path_enumerates_named_and_nested_selections
paths = JsonMask.compile('id,assets(url,width),assets/url').each_path

assert_kind_of Enumerator, paths
assert_equal [['id'], %w[assets url], %w[assets width]], paths.to_a
end

def test_each_path_yields_paths_and_returns_the_mask
mask = JsonMask.compile('id,assets/url')
paths = []

assert_same(mask, mask.each_path { |path| paths << path })
assert_equal [['id'], %w[assets url]], paths
end

def test_each_path_keeps_nested_wildcards_separate_from_named_paths
mask = JsonMask.compile('*(id),featured,detailed(name),items/*/url')

assert_equal(
[['featured'], %w[detailed name], ['items', JsonMask::WILDCARD, 'url'],
[JsonMask::WILDCARD, 'id']],
mask.each_path.to_a
)
end

def test_each_path_omits_descendants_of_whole_field_selections
assert_equal [['item']], JsonMask.compile('item/id,item,item/name').each_path.to_a
end

def test_each_path_omits_siblings_of_terminal_wildcards
assert_equal [[JsonMask::WILDCARD]], JsonMask.compile('*,typo/deep').each_path.to_a
assert_equal(
[['item', JsonMask::WILDCARD], ['id']],
JsonMask.compile('item(name,*,other/deep),id').each_path.to_a
)
end

def test_each_path_preserves_escaped_names_and_distinguishes_literal_stars
paths = JsonMask.compile('a\/b,\*,*/id').each_path.to_a

assert_equal [['a/b'], ['*'], [JsonMask::WILDCARD, 'id']], paths
refute_equal '*', JsonMask::WILDCARD
end

def test_each_path_yields_nothing_for_blank_selectors
[nil, '', " \n\t"].each do |fields|
assert_empty JsonMask.compile(fields).each_path.to_a
end
end

def test_mutating_yielded_path_arrays_does_not_change_the_mask
mask = JsonMask.compile('item(id,name)')
mask.each_path { |path| path.replace(['changed']) }

assert_equal [%w[item id], %w[item name]], mask.each_path.to_a
input = { 'item' => { 'id' => 1, 'name' => 'Demo', 'other' => true } }
assert_equal({ 'item' => { 'id' => 1, 'name' => 'Demo' } }, mask.call(input))
end

def test_format_path_escapes_literal_names_and_preserves_wildcards
assert_equal 'a\/b/name', JsonMask.format_path(['a/b', 'name'])
assert_equal '\*/id', JsonMask.format_path(['*', 'id'])
assert_equal '*/id', JsonMask.format_path([JsonMask::WILDCARD, 'id'])
assert_equal '', JsonMask.format_path([])
end

def test_format_path_round_trips_structural_characters_whitespace_and_unicode
path = ['a/b', 'a,b', 'a(b)', '*', '\\', ' spaced ', "\tline\n", '雪', JsonMask::WILDCARD]

assert_equal [path], JsonMask.compile(JsonMask.format_path(path)).each_path.to_a
end

def test_format_path_round_trips_every_ascii_character_as_a_field_name
# NUL and whitespace-only names need escaping to survive the parser's blank check.
(0..127).each do |codepoint|
path = [codepoint.chr]

assert_equal [path], JsonMask.compile(JsonMask.format_path(path)).each_path.to_a
end
end

def test_formatted_paths_reproduce_the_original_projection
mask = JsonMask.compile('id,items(url,name),\*,*/id')
fields = mask.each_path.map { |path| JsonMask.format_path(path) }.join(',')
input = {
'id' => 1, '*' => 'literal',
'items' => [{ 'id' => 2, 'url' => '/demo', 'name' => 'Demo', 'extra' => true }],
'owner' => { 'id' => 3, 'name' => 'Owner' }
}

assert_equal mask.call(input), JsonMask.call(input, fields)
end

def test_invalid_field_types_are_rejected
error = assert_raises(TypeError) { JsonMask.compile(['id']) }

Expand Down