From 818df9f4e318ce9be39dfb57fbc141949a2cc1a5 Mon Sep 17 00:00:00 2001 From: Robert Sheldon Date: Wed, 9 Sep 2026 13:43:04 -0400 Subject: [PATCH] Expose selected paths and format escaped selectors --- CHANGELOG.md | 7 +++ README.md | 42 +++++++++++++++ lib/json_mask.rb | 12 +++++ lib/json_mask/compiled_mask.rb | 29 +++++++++++ lib/json_mask/version.rb | 2 +- test/json_mask_test.rb | 93 ++++++++++++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbaa98e..8ef2c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c05b312..2321dbf 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/lib/json_mask.rb b/lib/json_mask.rb index 42e3f02..f714451 100644 --- a/lib/json_mask.rb +++ b/lib/json_mask.rb @@ -8,6 +8,8 @@ require_relative 'json_mask/compiled_mask' module JsonMask + WILDCARD = :* + class << self def call(value, fields, **options) compile(fields, **options).call(value) @@ -15,6 +17,16 @@ def call(value, fields, **options) alias mask call + # Formats a path from CompiledMask#each_path, or a prefix of one, as a selector. + # + # @param path [Array] 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, diff --git a/lib/json_mask/compiled_mask.rb b/lib/json_mask/compiled_mask.rb index 6118db0..d5a37fb 100644 --- a/lib/json_mask/compiled_mask.rb +++ b/lib/json_mask/compiled_mask.rb @@ -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] 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 diff --git a/lib/json_mask/version.rb b/lib/json_mask/version.rb index 141f165..6ebb3b3 100644 --- a/lib/json_mask/version.rb +++ b/lib/json_mask/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module JsonMask - VERSION = '0.2.0' + VERSION = '0.3.0' end diff --git a/test/json_mask_test.rb b/test/json_mask_test.rb index bf83b66..007d9aa 100644 --- a/test/json_mask_test.rb +++ b/test/json_mask_test.rb @@ -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']) }