From eda5478de9fbda99967bbb463c972b19c95d7049 Mon Sep 17 00:00:00 2001 From: Robert Sheldon Date: Thu, 3 Sep 2026 15:52:48 -0400 Subject: [PATCH 1/4] Expose compiled selectors as a public selection tree CompiledMask#selection_tree returns the root SelectionTree, and SelectionTree and Selection are public constants. Their readers (named, wildcard, selection_for, leaf?, children) let an application walk a selector against a response schema and reject fields it does not declare, which the projector alone cannot do because it only ever sees data. Construction and merging stay internal (@api private). Bump to 0.2.0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ALGXui8ksphAfb5D7ifG55 --- CHANGELOG.md | 9 ++- README.md | 63 +++++++++++++++++- lib/json_mask.rb | 2 +- lib/json_mask/compiled_mask.rb | 8 +++ lib/json_mask/selection_tree.rb | 35 +++++++++- lib/json_mask/version.rb | 2 +- test/json_mask_test.rb | 111 ++++++++++++++++++++++++++++++++ 7 files changed, 223 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5f3bb..f428176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog -## 0.1.0 - Unreleased +## 0.2.0 - Unreleased + +- 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 102e6ac..72027e9 100644 --- a/lib/json_mask.rb +++ b/lib/json_mask.rb @@ -43,5 +43,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..363cc2f 100644 --- a/lib/json_mask/compiled_mask.rb +++ b/lib/json_mask/compiled_mask.rb @@ -3,8 +3,16 @@ module JsonMask # An immutable, reusable field selector. class CompiledMask + # @return [String, nil] the selector expression as given attr_reader :fields + # The compiled selections, for callers that inspect a selector rather than + # apply it, such as checking field names against a response schema. + # + # @return [SelectionTree, nil] the root tree, or nil when the selector was + # blank and values pass through unchanged + attr_reader :selection_tree + def initialize(fields, selection_tree) @fields = fields&.dup&.freeze @selection_tree = selection_tree diff --git a/lib/json_mask/selection_tree.rb b/lib/json_mask/selection_tree.rb index 5926dfa..46c89e3 100644 --- a/lib/json_mask/selection_tree.rb +++ b/lib/json_mask/selection_tree.rb @@ -1,23 +1,32 @@ # frozen_string_literal: true module JsonMask - # Represents either a complete field or a field with nested selections. + # One entry of a {SelectionTree}: either a leaf, which selects a field's + # whole value, or a nested selection into that value's members. + # + # Selections are immutable. Read them from a {SelectionTree}; the + # constructors are internal to the parser. class Selection + # @return [SelectionTree, nil] the nested selections, or nil for a leaf attr_reader :children + # @api private def self.leaf @leaf ||= new end + # @api private def initialize(children = nil) @children = children freeze end + # @return [Boolean] true when the field's whole value is selected def leaf? children.nil? end + # @api private def merge(other) return self.class.leaf if leaf? || other.leaf? @@ -25,14 +34,26 @@ def merge(other) end end - # Stores named and wildcard selections, merging repeated selections by union. + # One level of a compiled selector: the fields named explicitly plus an + # optional wildcard. Repeated names are merged by union, and a leaf wins + # over a nested selection of the same name. + # + # Trees are immutable. Read a selector's root tree from + # {CompiledMask#selection_tree}; the constructors are internal to the parser. class SelectionTree - attr_reader :named, :wildcard + # @return [Hash{String => Selection}] the explicitly named selections, + # keyed by unescaped field name + attr_reader :named + # @return [Selection, nil] the +*+ selection at this level, if any + attr_reader :wildcard + + # @api private def self.empty @empty ||= new end + # @api private def self.single(name:, wildcard:, children: nil) selection = children ? Selection.new(children) : Selection.leaf @@ -43,6 +64,7 @@ def self.single(name:, wildcard:, children: nil) end end + # @api private def initialize(named: {}, wildcard: nil) @named = named.freeze @wildcard = wildcard @@ -50,6 +72,7 @@ def initialize(named: {}, wildcard: nil) freeze end + # @api private def merge(other) self.class.new( named: named.merge(other.named) { |_name, left, right| left.merge(right) }, @@ -57,6 +80,12 @@ def merge(other) ) end + # The selection the projector applies to a field: the named selection + # merged with the wildcard's nested selections, or the wildcard alone for + # a field that is not named. + # + # @param key [String, Symbol] a field name + # @return [Selection, nil] nil when the field is not selected 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 From 461ad77259ca9a946566772d84d9f1e52a735dbc Mon Sep 17 00:00:00 2001 From: Robert Sheldon Date: Thu, 3 Sep 2026 16:43:19 -0400 Subject: [PATCH 2/4] Drop YARD tags in favor of plain comments The gem does not use YARD; keep the prose that explains behavior and remove the @param/@return/@raise/@api markup, including the tags that predate the selection tree API. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ALGXui8ksphAfb5D7ifG55 --- lib/json_mask.rb | 14 ++++-------- lib/json_mask/compiled_mask.rb | 12 +++------- lib/json_mask/selection_tree.rb | 39 ++++++++++----------------------- 3 files changed, 19 insertions(+), 46 deletions(-) diff --git a/lib/json_mask.rb b/lib/json_mask.rb index 72027e9..45002c8 100644 --- a/lib/json_mask.rb +++ b/lib/json_mask.rb @@ -10,22 +10,16 @@ # 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 + # Filters a JSON-compatible value with a field selector. Blank selectors + # return the value unchanged. 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 + # Compiles a selector for reuse across multiple values. Raises ParseError + # for a malformed selector and LimitError when it exceeds a limit. 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 363cc2f..95a9153 100644 --- a/lib/json_mask/compiled_mask.rb +++ b/lib/json_mask/compiled_mask.rb @@ -3,14 +3,11 @@ module JsonMask # An immutable, reusable field selector. class CompiledMask - # @return [String, nil] the selector expression as given attr_reader :fields - # The compiled selections, for callers that inspect a selector rather than - # apply it, such as checking field names against a response schema. - # - # @return [SelectionTree, nil] the root tree, or nil when the selector was - # blank and values pass through unchanged + # The compiled SelectionTree, for callers that inspect a selector rather + # than apply it, such as checking field names against a response schema. + # Nil when the selector was blank and values pass through unchanged. attr_reader :selection_tree def initialize(fields, selection_tree) @@ -20,9 +17,6 @@ def initialize(fields, selection_tree) 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/selection_tree.rb b/lib/json_mask/selection_tree.rb index 46c89e3..9fe0c1f 100644 --- a/lib/json_mask/selection_tree.rb +++ b/lib/json_mask/selection_tree.rb @@ -1,32 +1,26 @@ # frozen_string_literal: true module JsonMask - # One entry of a {SelectionTree}: either a leaf, which selects a field's - # whole value, or a nested selection into that value's members. - # - # Selections are immutable. Read them from a {SelectionTree}; the - # constructors are internal to the parser. + # One entry of a SelectionTree: either a leaf, which selects a field's whole + # value, or a nested selection into that value's members. Selections are + # immutable and built only by the parser. class Selection - # @return [SelectionTree, nil] the nested selections, or nil for a leaf + # The nested SelectionTree, or nil for a leaf. attr_reader :children - # @api private def self.leaf @leaf ||= new end - # @api private def initialize(children = nil) @children = children freeze end - # @return [Boolean] true when the field's whole value is selected def leaf? children.nil? end - # @api private def merge(other) return self.class.leaf if leaf? || other.leaf? @@ -35,25 +29,20 @@ def merge(other) end # One level of a compiled selector: the fields named explicitly plus an - # optional wildcard. Repeated names are merged by union, and a leaf wins - # over a nested selection of the same name. - # - # Trees are immutable. Read a selector's root tree from - # {CompiledMask#selection_tree}; the constructors are internal to the parser. + # optional wildcard. Repeated names are merged by union, and a leaf wins over + # a nested selection of the same name. Trees are immutable and built only by + # the parser; read a selector's root tree from CompiledMask#selection_tree. class SelectionTree - # @return [Hash{String => Selection}] the explicitly named selections, - # keyed by unescaped field name + # The explicitly named selections, keyed by unescaped field name. attr_reader :named - # @return [Selection, nil] the +*+ selection at this level, if any + # The `*` selection at this level, if any. attr_reader :wildcard - # @api private def self.empty @empty ||= new end - # @api private def self.single(name:, wildcard:, children: nil) selection = children ? Selection.new(children) : Selection.leaf @@ -64,7 +53,6 @@ def self.single(name:, wildcard:, children: nil) end end - # @api private def initialize(named: {}, wildcard: nil) @named = named.freeze @wildcard = wildcard @@ -72,7 +60,6 @@ def initialize(named: {}, wildcard: nil) freeze end - # @api private def merge(other) self.class.new( named: named.merge(other.named) { |_name, left, right| left.merge(right) }, @@ -81,11 +68,9 @@ def merge(other) end # The selection the projector applies to a field: the named selection - # merged with the wildcard's nested selections, or the wildcard alone for - # a field that is not named. - # - # @param key [String, Symbol] a field name - # @return [Selection, nil] nil when the field is not selected + # merged with the wildcard's nested selections, or the wildcard alone for a + # field that is not named. Accepts String or Symbol keys and returns nil + # when the field is not selected. def selection_for(key) @effective_named.fetch(key.to_s, wildcard) end From 7dc4608f4ffb3c3bda0e97a9a527d299b7748e82 Mon Sep 17 00:00:00 2001 From: Robert Sheldon Date: Fri, 4 Sep 2026 10:54:40 -0400 Subject: [PATCH 3/4] Reserve comments for surprising behavior Drop class summaries and comments that restate the code; condense the ones that explain a non-obvious decision (null passthrough, leaf-wins merging, wildcard folding, the LimitError hierarchy, the dashed entry file, nil selection trees). Disable Style/Documentation to match. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ALGXui8ksphAfb5D7ifG55 --- .rubocop.yml | 4 ++++ lib/json-mask.rb | 3 +-- lib/json_mask.rb | 5 ----- lib/json_mask/compiled_mask.rb | 6 +----- lib/json_mask/error.rb | 3 +-- lib/json_mask/parser.rb | 1 - lib/json_mask/projector.rb | 6 ++---- lib/json_mask/selection_tree.rb | 21 ++++----------------- 8 files changed, 13 insertions(+), 36 deletions(-) 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/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 45002c8..42e3f02 100644 --- a/lib/json_mask.rb +++ b/lib/json_mask.rb @@ -7,19 +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. Blank selectors - # return the value unchanged. def call(value, fields, **options) compile(fields, **options).call(value) end alias mask call - # Compiles a selector for reuse across multiple values. Raises ParseError - # for a malformed selector and LimitError when it exceeds a limit. 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 95a9153..6118db0 100644 --- a/lib/json_mask/compiled_mask.rb +++ b/lib/json_mask/compiled_mask.rb @@ -1,13 +1,10 @@ # frozen_string_literal: true module JsonMask - # An immutable, reusable field selector. class CompiledMask attr_reader :fields - # The compiled SelectionTree, for callers that inspect a selector rather - # than apply it, such as checking field names against a response schema. - # Nil when the selector was blank and values pass through unchanged. + # nil for a blank selector, which passes values through unchanged. attr_reader :selection_tree def initialize(fields, selection_tree) @@ -16,7 +13,6 @@ def initialize(fields, selection_tree) freeze end - # Filters a JSON-compatible value using this selector. 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 9fe0c1f..6da0b8c 100644 --- a/lib/json_mask/selection_tree.rb +++ b/lib/json_mask/selection_tree.rb @@ -1,11 +1,7 @@ # frozen_string_literal: true module JsonMask - # One entry of a SelectionTree: either a leaf, which selects a field's whole - # value, or a nested selection into that value's members. Selections are - # immutable and built only by the parser. class Selection - # The nested SelectionTree, or nil for a leaf. attr_reader :children def self.leaf @@ -21,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? @@ -28,16 +25,8 @@ def merge(other) end end - # One level of a compiled selector: the fields named explicitly plus an - # optional wildcard. Repeated names are merged by union, and a leaf wins over - # a nested selection of the same name. Trees are immutable and built only by - # the parser; read a selector's root tree from CompiledMask#selection_tree. class SelectionTree - # The explicitly named selections, keyed by unescaped field name. - attr_reader :named - - # The `*` selection at this level, if any. - attr_reader :wildcard + attr_reader :named, :wildcard def self.empty @empty ||= new @@ -67,10 +56,8 @@ def merge(other) ) end - # The selection the projector applies to a field: the named selection - # merged with the wildcard's nested selections, or the wildcard alone for a - # field that is not named. Accepts String or Symbol keys and returns nil - # when the field is not selected. + # 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 From 68c3ae5f5e450ab3743d5cd4193295f58739cb3c Mon Sep 17 00:00:00 2001 From: Robert Sheldon Date: Fri, 4 Sep 2026 15:29:51 -0400 Subject: [PATCH 4/4] Date the 0.2.0 changelog entry Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ALGXui8ksphAfb5D7ifG55 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f428176..fbaa98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.2.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