Class: Plumb::HashClass

Inherits:
Object
  • Object
show all
Includes:
Composable
Defined in:
lib/plumb/hash_class.rb

Constant Summary collapse

NOT_A_HASH =
{ _: 'must be a Hash' }.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

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

#parse, #resolve

Constructor Details

#initialize(schema: BLANK_HASH, closed: false) ⇒ HashClass

Returns a new instance of HashClass.

Parameters:

  • schema (Hash) (defaults to: BLANK_HASH)

    field definitions keyed by literal or matcher keys

  • closed (Boolean) (defaults to: false)

    whether values contain only declared keys



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

#_schemaObject (readonly)

Returns the value of attribute _schema.



16
17
18
# File 'lib/plumb/hash_class.rb', line 16

def _schema
  @_schema
end

#catch_all_typeObject (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_fieldsObject (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_fieldsObject (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.

Parameters:

  • other (Object)

Returns:



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_typeObject

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

#closedHashClass

Returns the closed form emitted by a literal-key schema. Matcher-key and empty schemas remain open because they can pass unknown keys.

Returns:



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.

Returns:

  • (Boolean)

    whether values contain only declared keys



40
# File 'lib/plumb/hash_class.rb', line 40

def closed? = @closed

#filteredObject

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.

Returns:

  • (Boolean)


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_typeObject

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: []

A Hash type with a specific schema. Option 1: a Hash representing schema

Types::Hash[name: Types::String.present, age?: Types::Integer]

Option 2: a Map with pre-defined types for all keys and values

Types::Hash[Types::String, Types::Integer]


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.

Returns:

  • (Boolean)


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

#symbolizedObject

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_hObject



152
# File 'lib/plumb/hash_class.rb', line 152

def to_h = _schema