diff --git a/.rubocop.yml b/.rubocop.yml index b2562f5..0b39019 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5f3bb..fbaa98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index a27debb..c05b312 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/json-mask.rb b/lib/json-mask.rb index 03d1489..89022c0 100644 --- a/lib/json-mask.rb +++ b/lib/json-mask.rb @@ -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' diff --git a/lib/json_mask.rb b/lib/json_mask.rb index 102e6ac..42e3f02 100644 --- a/lib/json_mask.rb +++ b/lib/json_mask.rb @@ -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, @@ -43,5 +32,5 @@ def compile( end end - private_constant :Parser, :Projector, :Selection, :SelectionTree + private_constant :Parser, :Projector end diff --git a/lib/json_mask/compiled_mask.rb b/lib/json_mask/compiled_mask.rb index ebd6747..6118db0 100644 --- a/lib/json_mask/compiled_mask.rb +++ b/lib/json_mask/compiled_mask.rb @@ -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 diff --git a/lib/json_mask/error.rb b/lib/json_mask/error.rb index b64a6b9..d9a91e8 100644 --- a/lib/json_mask/error.rb +++ b/lib/json_mask/error.rb @@ -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 @@ -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 diff --git a/lib/json_mask/parser.rb b/lib/json_mask/parser.rb index 8712410..63e30a3 100644 --- a/lib/json_mask/parser.rb +++ b/lib/json_mask/parser.rb @@ -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 diff --git a/lib/json_mask/projector.rb b/lib/json_mask/projector.rb index 5c61512..94a02b0 100644 --- a/lib/json_mask/projector.rb +++ b/lib/json_mask/projector.rb @@ -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 @@ -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 diff --git a/lib/json_mask/selection_tree.rb b/lib/json_mask/selection_tree.rb index 5926dfa..6da0b8c 100644 --- a/lib/json_mask/selection_tree.rb +++ b/lib/json_mask/selection_tree.rb @@ -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 @@ -18,6 +17,7 @@ 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? @@ -25,7 +25,6 @@ def merge(other) end end - # Stores named and wildcard selections, merging repeated selections by union. class SelectionTree attr_reader :named, :wildcard @@ -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 diff --git a/lib/json_mask/version.rb b/lib/json_mask/version.rb index a7ad417..141f165 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.1.0' + VERSION = '0.2.0' end diff --git a/test/json_mask_test.rb b/test/json_mask_test.rb index 4ba6706..bf83b66 100644 --- a/test/json_mask_test.rb +++ b/test/json_mask_test.rb @@ -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