Class: Plumb::HashClass
- Inherits:
-
Object
- Object
- Plumb::HashClass
- Includes:
- Composable
- Defined in:
- lib/plumb/hash_class.rb
Constant Summary collapse
- NOT_A_HASH =
{ _: 'must be a Hash' }.freeze
Instance Attribute Summary collapse
-
#_schema ⇒ Object
readonly
Returns the value of attribute _schema.
-
#catch_all_type ⇒ Object
readonly
The
_catch-all value type (what every otherwise-unmatched key must be), or nil when the schema is closed. -
#literal_fields ⇒ Object
readonly
The literal (Symbol/String) entries of the schema.
-
#matcher_fields ⇒ Object
readonly
The matcher (typed/catch-all) entries of the schema.
Instance Method Summary collapse
-
#&(other) ⇒ HashClass, Composable
Intersects shared fields and any fields admitted by the other schema's catch-all.
-
#+(other) ⇒ Object
Hash#merge keeps the left-side key in the new hash if they match via #hash and #eql? we need to keep the right-side key, because even if the key name is the same, it's optional flag might have changed.
- #==(other) ⇒ Object
-
#accepted_type ⇒ Object
As a consumer, this Hash accepts each field relaxed to what that field accepts — so a converting field (eg.
price: Integer.build(Money)) accepts an Integer, not the Money it produces. - #at_key(a_key) ⇒ Object
- #call(result) ⇒ Object
-
#closed ⇒ HashClass
Returns the closed form emitted by a literal-key schema.
-
#closed? ⇒ Boolean
Input schemas are open because #call ignores extra keys; produced literal-key records are closed because #call emits only declared keys.
-
#filtered ⇒ Object
A lenient version of this Hash: it accepts any Hash and emits one with only the valid schema fields, dropping invalid/missing/extra ones.
-
#initialize(schema: BLANK_HASH, closed: false) ⇒ HashClass
constructor
A new instance of HashClass.
-
#only_catch_all? ⇒ Boolean
Only a single
_catch-all and no named/typed keys — the "open any-key map" shape (Hash[_: V]). -
#output_type ⇒ Object
The value you GET after running the schema: each field resolved to what it produces, so
Hash[age: String >> Integer].output_typeisHash[age: Integer](mirror of #accepted_type on the input side). -
#schema(*args) ⇒ Object
(also: #[])
A Hash type with a specific schema.
-
#subtype_of?(other) ⇒ Boolean
Width + depth subtyping over Hash schemas.
-
#symbolized ⇒ Object
A version of this Hash that first symbolizes string keys (via Types::SymbolizedHash) and then validates against this schema.
- #tagged_by(key, *types) ⇒ Object
- #to_h ⇒ Object
Methods included from Composable
#/, #>>, #absorb_input, #absorb_output, #as_node, #build, #check, #children, #defer, #fusable_step?, #fuse_with, #generate, #idempotent?, included, #input_type, #invalid, #invoke, #match, #metadata, #not, #pipeline, #policy, resolve_operand, #static, #subtype_identity, #to_json_schema, #to_mermaid, #to_plumb_type, #to_s, #transform, #value, #value_preserving?, #where, #with, wrap, #|
Methods included from Callable
Constructor Details
#initialize(schema: BLANK_HASH, closed: false) ⇒ HashClass
Returns a new instance of HashClass.
20 21 22 23 24 25 26 27 28 29 30 31 |
# File 'lib/plumb/hash_class.rb', line 20 def initialize(schema: BLANK_HASH, closed: false) @closed = closed @_schema = wrap_keys_and_values(schema) # Partition once (the instance is frozen): literal keys use exact lookup in # #call; matcher keys (typed keys and the `_` catch-all) are matched against # leftover input keys. `_schema` stays the source of truth for ==/subtyping. @literal_fields = @_schema.select { |k, _| k.literal? } @matcher_fields = @_schema.reject { |k, _| k.literal? } # The `_` catch-all's value type, if present (there is at most one). @catch_all_type = @_schema.find { |k, _| k.catch_all? }&.last freeze end |
Instance Attribute Details
#_schema ⇒ Object (readonly)
Returns the value of attribute _schema.
16 17 18 |
# File 'lib/plumb/hash_class.rb', line 16 def _schema @_schema end |
#catch_all_type ⇒ Object (readonly)
The _ catch-all value type (what every otherwise-unmatched key must be),
or nil when the schema is closed. See #call / #&.
35 36 37 |
# File 'lib/plumb/hash_class.rb', line 35 def catch_all_type @catch_all_type end |
#literal_fields ⇒ Object (readonly)
The literal (Symbol/String) entries of the schema.
52 53 54 |
# File 'lib/plumb/hash_class.rb', line 52 def literal_fields @literal_fields end |
#matcher_fields ⇒ Object (readonly)
The matcher (typed/catch-all) entries of the schema.
55 56 57 |
# File 'lib/plumb/hash_class.rb', line 55 def matcher_fields @matcher_fields end |
Instance Method Details
#&(other) ⇒ HashClass, Composable
Intersects shared fields and any fields admitted by the other schema's catch-all. The empty schema is the Hash top; disjoint non-empty schemas produce Never. Non-Hash operands use the generic intersection.
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/plumb/hash_class.rb', line 102 def &(other) # Route through the intersection hook (not bare Composable.wrap) so a # context-resolving operand — eg. an Encoder class — orients against this # Hash instead of defaulting to its decode direction (which would make # `Hash & EncoderClass` collapse to Never). Mirrors Composable#&. other = Composable.resolve_operand(other, op: :&, left: self) return super unless other.is_a?(HashClass) # The any-Hash top is the identity of intersection: Hash[] & X == X. return other if _schema.empty? return self if other._schema.empty? my_catch = catch_all_type their_catch = other.catch_all_type result = {} non_catch_all_schema.each do |my_key, my_field| if (other_key = other.stored_key(my_key)) # shared key: intersect fields; optionality from `other` (right wins). result[other_key] = my_field & other._schema[other_key] elsif their_catch # kept only if the other side's catch-all admits it result[my_key] = my_field & their_catch end end other.non_catch_all_schema.each do |their_key, their_field| next if stored_key(their_key) # already handled as a shared key result[their_key] = their_field & my_catch if my_catch end # Preserve every non-Any constraint on unmatched keys. A closed side may drop # extras, but an open side still validates them; dropping its lone catch-all # would admit values that the open side rejects. tail = my_catch && their_catch ? my_catch & their_catch : (my_catch || their_catch) result[Key.new(Types::Any)] = tail if tail && !tail.is_a?(AnyClass) return Types::Never if result.empty? # two closed schemas that share nothing self.class.new(schema: result) end |
#+(other) ⇒ Object
Hash#merge keeps the left-side key in the new hash if they match via #hash and #eql? we need to keep the right-side key, because even if the key name is the same, it's optional flag might have changed
86 87 88 89 90 91 92 93 94 95 |
# File 'lib/plumb/hash_class.rb', line 86 def +(other) other_schema = case other when HashClass then other._schema when ::Hash then other else raise ArgumentError, "expected a HashClass or Hash, got #{other.class}" end self.class.new(schema: merge_rightmost_keys(_schema, other_schema)) end |
#==(other) ⇒ Object
265 266 267 268 269 270 271 272 273 274 275 |
# File 'lib/plumb/hash_class.rb', line 265 def ==(other) return false unless other.is_a?(self.class) && _schema.size == other._schema.size # `Key#eql?`/`#hash` compare names only, so a raw `_schema == _schema` # would treat `name?:` and `name:` as equal. Two Hash types that differ in # a key's optionality are different types, so compare that too. _schema.all? do |key, value| other_key, other_value = other._schema.find { |k, _| k.eql?(key) } other_key && key.optional? == other_key.optional? && value == other_value end end |
#accepted_type ⇒ Object
As a consumer, this Hash accepts each field relaxed to what that field
accepts — so a converting field (eg. price: Integer.build(Money)) accepts
an Integer, not the Money it produces. Only field types change; keys and
optionality are preserved, so ordinary record subtyping is unchanged. This
is what lets Hash[price: Integer] >> Hash[price: Integer.build(Money)]
type-check (the front-end/back-end coercion pattern).
301 302 303 304 305 306 |
# File 'lib/plumb/hash_class.rb', line 301 def accepted_type relaxed = _schema.each_with_object({}) do |(key, field), h| h[key] = Plumb::Subtyping.accepted_type(field) end self.class.new(schema: relaxed) end |
#at_key(a_key) ⇒ Object
148 149 150 |
# File 'lib/plumb/hash_class.rb', line 148 def at_key(a_key) _schema[Key.wrap(a_key)] end |
#call(result) ⇒ Object
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 |
# File 'lib/plumb/hash_class.rb', line 207 def call(result) return result.invalid!(errors: NOT_A_HASH) unless result.value.is_a?(::Hash) return result unless _schema.any? input = result.value errors = nil # Do not allocate errors unless needed output = {} # Pass 1 — literal keys by exact lookup (the fast path). Reuse the incoming # cursor as the per-field scratch: `input` is captured above and `result` is # not read again until the final flip below, so each field can reset it in # place — a Hash validates with zero Result allocations of its own. # `output`/`errors` hold the fields' values/errors by reference (read out # immediately per field), and #reset only reassigns the cursor's slots, so # previously stored entries are never mutated. This mirrors ArrayClass's # element-cursor reuse; a field whose value is lazily consumed later (a # Stream) snapshots its own source, so it stays correct. @literal_fields.each do |key, field| key_s = key.to_key if input.key?(key_s) r = field.call(result.reset(input[key_s])) output[key_s] = r.value unless r.valid? errors ||= {} errors[key_s] = r.errors end elsif !key.optional? r = field.call(result.reset(Undefined)) output[key_s] = r.value unless r.value == Undefined unless r.valid? errors ||= {} errors[key_s] = r.errors end end end # Pass 2 — leftover input keys against matcher keys (typed keys + the `_` # catch-all), first match wins. Keys matching nothing are dropped (the # non-inclusive default). Matcher keys never impose a required key. unless @matcher_fields.empty? input.each do |k, v| next if output.key?(k) match = @matcher_fields.find { |mk, _| mk.match?(k) } next unless match r = match[1].call(result.reset(v)) output[k] = r.value unless r.valid? errors ||= {} errors[k] = r.errors end end end errors ? result.invalid!(output, errors:) : result.valid!(output) end |
#closed ⇒ HashClass
Returns the closed form emitted by a literal-key schema. Matcher-key and empty schemas remain open because they can pass unknown keys.
45 46 47 48 49 |
# File 'lib/plumb/hash_class.rb', line 45 def closed return self if @closed || _schema.empty? || !@matcher_fields.empty? self.class.new(schema: _schema, closed: true) end |
#closed? ⇒ Boolean
Input schemas are open because #call ignores extra keys; produced literal-key records are closed because #call emits only declared keys.
40 |
# File 'lib/plumb/hash_class.rb', line 40 def closed? = @closed |
#filtered ⇒ Object
A lenient version of this Hash: it accepts any Hash and emits one with only
the valid schema fields, dropping invalid/missing/extra ones. As a type it
declares #input_type as this schema and #output_type as this schema
with every key relaxed to optional (any field may be dropped), so it
participates in subtyping (see FilteredHash).
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'lib/plumb/hash_class.rb', line 159 def filtered op = lambda do |result| return result.invalid!(errors: 'must be a Hash') unless result.value.is_a?(::Hash) return result unless _schema.any? input = result.value # Reuse the incoming cursor as the per-field scratch (see #call): `input` # is captured above and `result` is only flipped at the end, so fields # reset it in place with no scratch allocation. output = {} @literal_fields.each do |key, field| key_s = key.to_key if input.key?(key_s) r = field.call(result.reset(input[key_s])) output[key_s] = r.value if r.valid? elsif !key.optional? r = field.call(result.reset(Undefined)) output[key_s] = r.value if r.valid? end end unless @matcher_fields.empty? input.each do |k, v| next if output.key?(k) match = @matcher_fields.find { |mk, _| mk.match?(k) } next unless match r = match[1].call(result.reset(v)) output[k] = r.value if r.valid? end end result.valid!(output) end # `op` is built fresh per call, so name what the step IS as its identity — # otherwise every `.filtered` node is unequal to every other, and so is any # composite containing one. @see Function#== FilteredHash.new(self, relaxed_to_optional, op, identity: [:filtered_hash, self]) end |
#only_catch_all? ⇒ Boolean
Only a single _ catch-all and no named/typed keys — the "open any-key map"
shape (Hash[_: V]). Used by HashMap subtyping.
59 |
# File 'lib/plumb/hash_class.rb', line 59 def only_catch_all? = @literal_fields.empty? && @matcher_fields.size == 1 && !@catch_all_type.nil? |
#output_type ⇒ Object
The value you GET after running the schema: each field resolved to what it
produces, so Hash[age: String >> Integer].output_type is Hash[age: Integer] (mirror of #accepted_type on the input side). Idempotent — when
no field converts, every field's resolved output IS the field, so this
returns self and Subtyping.resolved_output reaches its fixpoint in one
step. Only field types change; keys and optionality are preserved.
314 315 316 317 318 319 |
# File 'lib/plumb/hash_class.rb', line 314 def output_type resolved = _schema.each_with_object({}) do |(key, field), h| h[key] = Plumb::Subtyping.resolved_output(field) end resolved.any? { |k, f| !f.equal?(_schema[k]) } ? self.class.new(schema: resolved) : self end |
#schema(*args) ⇒ Object Also known as: []
69 70 71 72 73 74 75 76 77 78 |
# File 'lib/plumb/hash_class.rb', line 69 def schema(*args) case args in [::Hash => hash] self.class.new(schema: _schema.merge(wrap_keys_and_values(hash))) in [key_type, value_type] HashMap.new(Composable.wrap(key_type), Composable.wrap(value_type)) else raise ::ArgumentError, "unexpected value to Types::Hash#schema #{args.inspect}" end end |
#subtype_of?(other) ⇒ Boolean
Width + depth subtyping over Hash schemas. self <= other when, for every
key other requires, self provides it as a required key whose type is
a subtype (depth) — self may add keys (width), and a key other makes
optional need not be present. A key other requires but self only holds
optionally is NOT enough: self could omit it. An empty schema
(Types::Hash) is the "any Hash" top within the Hash family.
283 284 285 286 287 288 289 290 291 292 293 |
# File 'lib/plumb/hash_class.rb', line 283 def subtype_of?(other) return true if self == other return hashmap_subtype?(other) if other.is_a?(HashMap) return false unless other.is_a?(HashClass) # The unconstrained Hash is the top of this family, never a narrower subtype. return false if _schema.empty? return true if other._schema.empty? named_keys_ok?(other) && carried_keys_ok?(other) && tail_ok?(other) end |
#symbolized ⇒ Object
A version of this Hash that first symbolizes string keys (via
Types::SymbolizedHash) and then validates against this schema. Use it
instead of Types::SymbolizedHash >> self, which the strict composition
check rejects — a Symbol-keyed map doesn't guarantee this schema's keys, so
this declares the step as a #transform (conversion) instead.
203 204 205 |
# File 'lib/plumb/hash_class.rb', line 203 def symbolized Types::SymbolizedHash / self end |
#tagged_by(key, *types) ⇒ Object
144 145 146 |
# File 'lib/plumb/hash_class.rb', line 144 def tagged_by(key, *types) TaggedHash.new(self, key, types) end |
#to_h ⇒ Object
152 |
# File 'lib/plumb/hash_class.rb', line 152 def to_h = _schema |