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
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ Metrics/ClassLength:
Max: 150
Exclude:
- "test/**/*"

# Comments are reserved for surprising behavior, so class summaries are not required.
Style/Documentation:
Enabled: false
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# Changelog

## 0.1.0 - Unreleased
## 0.2.0 - 2026-09-04

- Expose compiled selectors for inspection. `CompiledMask#selection_tree` returns the root
`JsonMask::SelectionTree`; its `named`, `wildcard`, and `selection_for` readers and
`JsonMask::Selection`'s `leaf?` and `children` are public API. Applications that have a
response schema can use the tree to reject selectors that name undeclared fields.

## 0.1.0 - 2026-08-04

- Implement Google partial-response and JSON Mask field selectors.
- Support comma-separated fields, slash paths, sub-selections, wildcards, and escaping.
Expand Down
63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,68 @@ Exceeding a limit raises `JsonMask::LimitError`, a subclass of `JsonMask::ParseE

Validation is syntactic. Because the library has no response schema, a well-formed selector that
names a field absent from the input simply omits that field; it cannot produce Google's
schema-aware "Invalid field selection" error on its own.
schema-aware "Invalid field selection" error on its own. An application that has a schema can
produce one by inspecting the compiled selector.

## Inspecting a compiled selector

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

```ruby
tree = JsonMask.compile("id,permissions(role),*/kind").selection_tree

tree.named.keys # => ["id", "permissions"]
tree.named["id"].leaf? # => true
tree.named["permissions"].children.named.keys # => ["role"]
tree.wildcard.children.named.keys # => ["kind"]
tree.selection_for("permissions").children.named.keys # => ["role", "kind"]
tree.selection_for("other").children.named.keys # => ["kind"]
```

- A `JsonMask::SelectionTree` holds `named`, a frozen `Hash` from each explicitly written field
name (unescaped) to a `JsonMask::Selection`, and `wildcard`, the `*` selection at that level or
`nil`.
- A `JsonMask::Selection` is either a leaf (`leaf?` is true and the whole value is selected) or
has `children`, a nested `JsonMask::SelectionTree`.
- `selection_for(key)` returns what the projector applies to a key (`String` or `Symbol`): the
named selection merged with the wildcard, the wildcard alone for a key that is not named, or
`nil` when the key is not selected.

Blank selectors compile to a mask that passes values through, and its `selection_tree` is `nil`.

This is enough to add schema-aware validation. For example, to list the selected paths that a
JSON-Schema-style hash does not declare:

```ruby
def undeclared_paths(tree, schema, path = [])
schema = schema["items"] if schema["type"] == "array"
properties = schema.fetch("properties", {})

tree.named.flat_map do |name, selection|
property = properties[name]
next [(path + [name]).join("/")] unless property
next [] if selection.leaf?

undeclared_paths(selection.children, property, path + [name])
end
end

schema = {
"type" => "object",
"properties" => {
"id" => {"type" => "string"},
"permissions" => {
"type" => "array",
"items" => {"type" => "object", "properties" => {"role" => {"type" => "string"}}}
}
}
}

tree = JsonMask.compile("id,permissions(role,email),owner/name").selection_tree
undeclared_paths(tree, schema)
# => ["permissions/email", "owner"]
```

## Compatibility

Expand Down
3 changes: 1 addition & 2 deletions lib/json-mask.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# frozen_string_literal: true

# Entry file matching the gem name, so Bundler's default require
# (`gem "json-mask"`) resolves without an explicit `require:` option.
# Bundler's default require for `gem "json-mask"` looks for this file name.
require_relative 'json_mask'
13 changes: 1 addition & 12 deletions lib/json_mask.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,14 @@
require_relative 'json_mask/projector'
require_relative 'json_mask/compiled_mask'

# Filters JSON-compatible Ruby values using Google partial-response selectors.
module JsonMask
class << self
# Filters a JSON-compatible value with a field selector.
#
# @param value [Hash, Array] response data to filter
# @param fields [String, nil] fields selector; blank values pass through unchanged
# @return [Hash, Array, nil] a structural subset of value
def call(value, fields, **options)
compile(fields, **options).call(value)
end

alias mask call

# Compiles a selector for reuse across multiple values.
#
# @return [CompiledMask] an immutable selector
# @raise [ParseError] if the selector is malformed
# @raise [LimitError] if the selector exceeds a configured limit
def compile(
fields,
max_length: Parser::DEFAULT_MAX_LENGTH,
Expand All @@ -43,5 +32,5 @@ def compile(
end
end

private_constant :Parser, :Projector, :Selection, :SelectionTree
private_constant :Parser, :Projector
end
8 changes: 3 additions & 5 deletions lib/json_mask/compiled_mask.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
# frozen_string_literal: true

module JsonMask
# An immutable, reusable field selector.
class CompiledMask
attr_reader :fields

# nil for a blank selector, which passes values through unchanged.
attr_reader :selection_tree

def initialize(fields, selection_tree)
@fields = fields&.dup&.freeze
@selection_tree = selection_tree
freeze
end

# Filters a JSON-compatible value using this selector.
#
# @param value [Hash, Array] response data to filter
# @return [Hash, Array, nil] a structural subset of value
def call(value)
return value unless @selection_tree

Expand Down
3 changes: 1 addition & 2 deletions lib/json_mask/error.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

module JsonMask
# Raised when a field selector does not conform to the supported grammar.
class ParseError < ArgumentError
attr_reader :expression, :offset, :reason

Expand All @@ -14,7 +13,7 @@ def initialize(reason, expression:, offset:)
end
end

# Raised when a field selector exceeds a configured parser limit.
# A ParseError subclass so one rescue covers malformed and oversized selectors.
class LimitError < ParseError
end
end
1 change: 0 additions & 1 deletion lib/json_mask/parser.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

module JsonMask
# Compiles a field selector string into an immutable selection tree.
class Parser
DEFAULT_MAX_LENGTH = 16_384
DEFAULT_MAX_DEPTH = 64
Expand Down
6 changes: 2 additions & 4 deletions lib/json_mask/projector.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

module JsonMask
# Applies a compiled selection tree to Hash and Array values.
module Projector
MISSING = Object.new.freeze

Expand All @@ -19,9 +18,8 @@ def project(value, selection_tree)
when Array
project_array(value, selection_tree)
when nil
# JSON null is a value, not a shape mismatch: a nested selection into
# null keeps the null (matching the reference implementation), so a
# nullable field stays distinguishable from an unselected one.
# Keep nulls, as the reference implementation does, so a nullable
# field stays distinguishable from an unselected one.
nil
else
MISSING
Expand Down
5 changes: 3 additions & 2 deletions lib/json_mask/selection_tree.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

module JsonMask
# Represents either a complete field or a field with nested selections.
class Selection
attr_reader :children

Expand All @@ -18,14 +17,14 @@ def leaf?
children.nil?
end

# A leaf already selects the whole value, so it absorbs any nested selection.
def merge(other)
return self.class.leaf if leaf? || other.leaf?

self.class.new(children.merge(other.children))
end
end

# Stores named and wildcard selections, merging repeated selections by union.
class SelectionTree
attr_reader :named, :wildcard

Expand Down Expand Up @@ -57,6 +56,8 @@ def merge(other)
)
end

# Unlike `named`, folds the wildcard's nested selections into each named
# selection; unnamed fields get the wildcard itself.
def selection_for(key)
@effective_named.fetch(key.to_s, wildcard)
end
Expand Down
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.1.0'
VERSION = '0.2.0'
end
111 changes: 111 additions & 0 deletions test/json_mask_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,115 @@ def test_limits_must_be_positive_integers

assert_equal 'max_depth must be a positive Integer', error.message
end

def test_compiled_masks_expose_their_selection_tree
tree = JsonMask.compile('id,permissions(role),items/*/id').selection_tree

assert_kind_of JsonMask::SelectionTree, tree
assert_equal %w[id permissions items], tree.named.keys
assert_nil tree.wildcard
assert_predicate tree.named['id'], :leaf?
assert_nil tree.named['id'].children
end

def test_selection_trees_nest_parenthesized_selections
permissions = JsonMask.compile('permissions(role)').selection_tree.named['permissions']

refute_predicate permissions, :leaf?
assert_equal ['role'], permissions.children.named.keys
assert_predicate permissions.children.named['role'], :leaf?
end

def test_selection_trees_nest_wildcard_paths
items = JsonMask.compile('items/*/id').selection_tree.named['items'].children

assert_empty items.named
assert_equal ['id'], items.wildcard.children.named.keys
end

def test_blank_selectors_compile_without_a_selection_tree
assert_nil JsonMask.compile(nil).selection_tree
assert_nil JsonMask.compile('').selection_tree
assert_nil JsonMask.compile(" \n\t").selection_tree
end

def test_selection_trees_are_frozen
tree = JsonMask.compile('id,nested/value').selection_tree

assert_predicate tree, :frozen?
assert_predicate tree.named, :frozen?
assert_predicate tree.named['nested'], :frozen?
assert_raises(FrozenError) { tree.named['other'] = tree.named['id'] }
end

def test_selection_trees_keep_wildcards_separate_from_named_selections
tree = JsonMask.compile('*(id),featured,detailed(name)').selection_tree

assert_equal %w[featured detailed], tree.named.keys
assert_equal ['id'], tree.wildcard.children.named.keys
end

def test_selection_for_merges_the_wildcard_into_named_selections
tree = JsonMask.compile('*(id),featured,detailed(name)').selection_tree

assert_predicate tree.selection_for('featured'), :leaf?
assert_equal %w[name id], tree.selection_for('detailed').children.named.keys
assert_same tree.wildcard, tree.selection_for('other')
end

def test_selection_for_accepts_string_and_symbol_keys_and_is_nil_for_unselected_fields
tree = JsonMask.compile('id').selection_tree

assert_same tree.named['id'], tree.selection_for('id')
assert_same tree.named['id'], tree.selection_for(:id)
assert_nil tree.selection_for('other')
end

def test_repeated_selections_are_merged_in_the_tree
tree = JsonMask.compile('item/id,item/name').selection_tree

assert_equal %w[id name], tree.named['item'].children.named.keys

tree = JsonMask.compile('item/id,item').selection_tree

assert_predicate tree.named['item'], :leaf?
end

def test_selection_tree_names_are_unescaped
tree = JsonMask.compile('a\/b,\*,a\(b\)').selection_tree

assert_equal ['a/b', '*', 'a(b)'], tree.named.keys
assert_nil tree.wildcard
end

def test_selection_trees_support_schema_aware_validation
schema = {
'type' => 'object',
'properties' => {
'id' => { 'type' => 'string' },
'permissions' => {
'type' => 'array',
'items' => { 'type' => 'object', 'properties' => { 'role' => { 'type' => 'string' } } }
}
}
}
tree = JsonMask.compile('id,permissions(role,email),owner/name').selection_tree

assert_equal ['permissions/email', 'owner'], undeclared_paths(tree, schema)
end

private

def undeclared_paths(tree, schema, path = [])
schema = schema['items'] if schema['type'] == 'array'
properties = schema.fetch('properties', {})

tree.named.flat_map do |name, selection|
property = properties[name]
next [(path + [name]).join('/')] unless property
next [] if selection.leaf?

undeclared_paths(selection.children, property, path + [name])
end
end
end