Module: Structured::ClassMethods

Defined in:
lib/structured.rb

Overview

Methods extended to a Structured class. A class would typically use the following methods within its class body:

  • #set_description to set a textual description of the object

  • #element to define expected elements of the input hash

  • #default_element to define processing of unknown element keys

The #explain method is also useful for printing out documentation for a Structured class.

Instance Method Summary collapse

Instance Method Details

#apply_val(obj, elt, val) ⇒ Object

Applies a value to an element for an object, after all processing for the value is done.



574
575
576
577
578
579
580
# File 'lib/structured.rb', line 574

def apply_val(obj, elt, val)
  if obj.respond_to?("receive_#{elt}")
    obj.send("receive_#{elt}".to_sym, val)
  else
    obj.instance_variable_set("@#{elt}", val)
  end
end

#build_from_hash(obj, hash) ⇒ Object

Given a hash, extracts all the elements from it and updates the object accordingly. This method is called automatically upon initialization of the Structured class.

Parameters:

  • obj

    the object to update

  • hash

    the data hash.



457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# File 'lib/structured.rb', line 457

def build_from_hash(obj, hash)
  input_err("Initializer is not a Hash") unless hash.is_a?(Hash)
  hash = try_read_file(hash)

  @elements.each do |key, data|
    Structured.trace(key.to_s) do
      val = hash[key] || hash[key.to_s]
      cval = process_value(obj, val, data)
      apply_val(obj, key, cval) unless cval.nil?
    end
  end

  # Process unknown elements
  unknown_keys = hash.keys.reject { |k| @elements.include?(k.to_sym) }
  return if unknown_keys.empty?
  unless @default_element
    input_err("Unexpected element(s): #{unknown_keys.join(', ')}")
  end
  unknown_keys.each do |key|
    Structured.trace(key.to_s) do
      val = hash[key]
      ckey = process_value(obj, key, @default_key)
      cval = process_value(obj, val, @default_element)
      next if cval.nil?
      cval.receive_key(ckey) if cval.is_a?(Structured)
      obj.receive_any(ckey, cval)
    end
  end
end

#convert_item(item, type, parent) ⇒ Object

Given an expected type and an item, checks that the item matches the expected type, and performs any necessary conversions.



594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
# File 'lib/structured.rb', line 594

def convert_item(item, type, parent)
  case type
    #
    # In the when cases, the type is not just a class object
    #
  when :boolean
    return item if item.is_a?(TrueClass) || item.is_a?(FalseClass)
    input_err("#{item} is not boolean")

  when Array
    input_err("#{item} is not Array") unless item.is_a?(Array)
    Structured.trace(Array) do
      item = try_read_array(item[1..-1]) if item.first.to_s == 'read_file'
      return item.map.with_index { |i, idx|
        Structured.trace(idx) do
          convert_item(i, type.first, parent)
        end
      }
    end

  when Hash
    input_err("#{item} is not Hash") unless item.is_a?(Hash)
    Structured.trace(Hash) do
      return item.map { |k, v|
        Structured.trace(k.to_s) do
          conv_key = convert_item(k, type.first.first, parent)
          conv_item = convert_item(v, type.first.last, parent)
          conv_item.receive_key(conv_key) if conv_item.is_a?(Structured)
          [ conv_key, conv_item ]
        end
      }.to_h
    end

  else

    #
    # In these cases, the type is a class object. It can't be tested with
    # the === operator of a case/when.
    #
    # If the item can be automatically coverted to the expected type
    citem = try_autoconvert(type, item)

    # If the item is of the expected type, then return it
    return citem if citem.is_a?(type)

    # The only remaining hope for conversion is that type is Structured and
    # item is a hash
    return convert_structured(citem, type, parent)
  end
end

#convert_structured(item, type, parent) ⇒ Object

Receive hash values that are to be converted to Structured objects



680
681
682
683
684
685
686
687
688
689
690
691
692
693
# File 'lib/structured.rb', line 680

def convert_structured(item, type, parent)
  unless item.is_a?(Hash)
    if type.include?(Structured)
      input_err("#{item.inspect} not a Structured hash for #{type}")
    else
      input_err("#{item.inspect} not a #{type}")
    end
  end

  unless type.include?(Structured) || type.include?(StructuredPolymorphic)
    input_err("#{type} is not a Structured class")
  end
  return type.new(item, parent)
end

#default_element(*args, **params) ⇒ Object

Accepts a default element for this class. The arguments are the same as those for element_data except as noted below.

If this method is called, then for any keys found in an input hash that have no corresponding #element declaration in the Structured class, the method receive_any will be invoked. The value from the input hash will be processed based on any type declaration, preproc, and check given to default_element.

The default element keys can be processed based on the argument key, which should be a hash corresponding to the element_data arguments plus the key :type with the default key’s expected type. If key is not given, then the key must be and is automatically converted to a Symbol.

Caution: The type argument should almost always be a single class, and not a hash. This is because the default arguments are automatically treated like a hash, with the otherwise-undefined element names being the keys of the hash.



343
344
345
346
347
348
349
350
351
352
# File 'lib/structured.rb', line 343

def default_element(*args, **params)
  if (key_params = params.delete(:key))
    @default_key = element_data(
      key_params.delete(:type) || Object, **key_params
    )
  else
    @default_key = element_data(Symbol, preproc: proc { |s| s.to_sym })
  end
  @default_element = element_data(*args, **params)
end

#describe_type(type) ⇒ Object

Provides a textual description of a type.



743
744
745
746
747
748
749
750
751
752
# File 'lib/structured.rb', line 743

def describe_type(type)
  case type
  when :boolean then 'Boolean'
  when Array then "Array of #{describe_type(type.first)}"
  when Hash
    desc1, desc2 = type.first.map { |x| describe_type(x) }
    "Hash of #{desc1} => #{desc2}"
  else return type.to_s
  end
end

#description(len = nil) ⇒ Object

Returns the class’s description. The given number can be used to limit the length of the description.



285
286
287
288
289
290
291
292
# File 'lib/structured.rb', line 285

def description(len = nil)
  desc = @class_description || ''
  if len && desc.length > len
    return desc[0, len] if len <= 5
    return desc[0, len - 3] + '...'
  end
  return desc
end

#each_elementObject

Iterates elements in a useful sorted order.



437
438
439
440
441
442
443
444
445
446
447
# File 'lib/structured.rb', line 437

def each_element
  @elements.sort_by { |e, data|
    if data[:optional] == :omit
      [ 3, e.to_s ]
    else
      [ data[:optional] ? 2 : 1, e.to_s ]
    end
  }.each do |e, data|
    yield(e, data)
  end
end

#element(name, *args, attr: true, **params) ⇒ Object

Declares that the class expects an element with the given name and type. See element_data for an explanation of *args and **params.

the given element. Default is true.

Parameters:

  • name (Symbol)

    The name of the element.

  • attr (defaults to: true)

    Whether to create an attribute (i.e., call attr_reader) for



302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/structured.rb', line 302

def element(name, *args, attr: true, **params)
  @elements[name.to_sym] = element_data(*args, **params)
  #
  # By default, when an element is received, a corresponding instance
  # variable is set. Classes using Structured can define +receive_[name]+ so
  # that the element declaration will perform other tasks.
  #
  # This creates the reader attribute only if there is no other method of
  # the same name.
  #
  attr_reader(name) if attr && !method_defined?(name)
end

#element_data(type, optional: false, description: nil, preproc: nil, default: nil, check: nil) ⇒ Object

Processes the definition of an element.

  • A class.

  • The value :boolean, indicating that a boolean is acceptable.

  • An array containing a single element being a class, signifying that the expected type is an array of elements matching that class.

  • A hash containing a single Class1 => Class2 pair, signifying that the expected type is a hash of key-value pairs matching the indicated classes. If Class2 is a Structured class, then Class2 objects will have their Structured#receive_key method called, with the corresponding Class1 object as the argument.

convert it. The proc will be executed in the context of the receiving object.

is also used for optional elements that are not specified in an input hash.

value. This may be:

  • A Proc, in which case it should return true for valid values.

  • An Array of valid values (tested by ===}).

  • Any other object, in which case validity is determined by whether the check value === the element value.

Parameters:

  • type

    The expected type of the element value. This may be:

  • optional (defaults to: false)

    Whether the element is optional. Set to :omit to omit it from templates.

  • description (defaults to: nil)

    A text description of the element.

  • preproc (defaults to: nil)

    A Proc that will be executed on the element value to

  • default (defaults to: nil)

    A default value, entered into templates. The default value

  • check (defaults to: nil)

    A mechanism for checking for the validity of an element



393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
# File 'lib/structured.rb', line 393

def element_data(
  type,
  optional: false, description: nil,
  preproc: nil, default: nil, check: nil
)
  # Check the type argument
  case type
  when Class, :boolean
  when Array
    unless type.count == 1 && type.first.is_a?(Class)
      raise TypeError, "Invalid Array type declaration"
    end
  when Hash
    unless type.count == 1 && type.first.all? { |x| x.is_a?(Class) }
      raise TypeError, "Invalid Hash type declaration"
    end
  else
    raise TypeError, "Invalid type declaration #{type.inspect}"
  end

  if preproc
    raise TypeError, "preproc must be a Proc" unless preproc.is_a?(Proc)
  end

  case check
  when nil, Proc then check_obj = check # Pass through
  when Array then check_obj = proc { |o| check.any? { |c| c === o } }
  else check_obj = proc { |o| check === o }
  end

  return {
    :type => type,
    :optional => optional,
    :description => description,
    :preproc => preproc,
    :default => default,
    :check => check_obj,
  }

end

#explain(io = STDOUT) ⇒ Object

Prints out documentation for this class.



707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
# File 'lib/structured.rb', line 707

def explain(io = STDOUT)
  io.puts("Structured Class #{self}:")
  if @class_description
    io.puts("\n" + TextTools.line_break(@class_description, prefix: '  '))
  end
  io.puts

  each_element do |elt, data|
    io.puts(
      "  #{elt}: #{describe_type(data[:type])}" + \
      "#{data[:optional] ? ' (optional)' : ''}"
    )
    if data[:description]
      io.puts(TextTools.line_break(data[:description], prefix: '    '))
      io.puts()
    end
  end

  if @default_element
    io.puts(
      "  All other elements: #{describe_type(@default_key[:type])} => " \
      "#{describe_type(@default_element[:type])}"
    )
    if @default_element[:description]
      io.puts(TextTools.line_break(
        @default_element[:description], prefix: '    '
      ))
    end
    io.puts()
  end

end

#input_err(text) ⇒ Object

Raises an InputError.

Raises:



699
700
701
# File 'lib/structured.rb', line 699

def input_err(text)
  raise InputError, text
end

#process_nil_val(val, data) ⇒ Object

Performs processing of an element value to deal with the possibility that the value is nil. This method returns [ the new value, boolean of whether to stop processing ] according to the following rules:

  • If val is non-nil, then this method returns val itself, and processing should not stop.

  • If val is nil and this element is non-optional, then this method raises an error.

  • If val is nil and the element is optional, then the object’s default value is returned, and processing should stop.

  • If there is no default value for an optional element, then nil is returned, and processing should also stop.



564
565
566
567
568
569
570
# File 'lib/structured.rb', line 564

def process_nil_val(val, data)
  return [ val, false ] unless val.nil?
  unless data[:optional]
    input_err("Required element is missing (or was deleted by a preproc)")
  end
  return [ data[:default], true ]
end

#process_value(obj, val, data) ⇒ Object

Given an element value and an #element_data hash of processing tools element, applies those processing tools. Namely, apply any preproc, check the type and perform other checks, and perform any conversions. The return value should be usable as the received value for the corresponding element.

If this method returns nil, then there is no element to process. This method may also raise an InputError.



534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
# File 'lib/structured.rb', line 534

def process_value(obj, val, data)
  val, ret = process_nil_val(val, data)
  return val if ret
  if data[:preproc]
    val = try_run(data[:preproc], obj, val, "preproc")
    val, ret = process_nil_val(val, data)
    return val if ret
  end

  cval = convert_item(val, data[:type], obj)
  if data[:check] && !try_run(data[:check], obj, cval, "check")
    input_err "Value #{cval} failed check"
  end
  return cval
end

#remove_element(name) ⇒ Object

Removes an element. Note that the attribute definition if any and the receive_[name] method are left intact.



319
320
321
# File 'lib/structured.rb', line 319

def remove_element(name)
  @elements.delete(name.to_sym)
end

#reset_elementsObject

Sets up a class to manage elements. This method is called when Structured is included in the class.

As an implementation note: Information about a Structured class is stored in instance variables of the class’s object.



267
268
269
270
271
272
# File 'lib/structured.rb', line 267

def reset_elements
  @elements = {}
  @default_element = nil
  @default_key = nil
  @class_description = nil
end

#set_description(desc) ⇒ Object

Provides a description of this class, for use with the #explain method.



277
278
279
# File 'lib/structured.rb', line 277

def set_description(desc)
  @class_description = desc
end

#subtypesObject

Returns a list of all Structured types that are in elements of this class.



757
758
759
760
761
762
763
# File 'lib/structured.rb', line 757

def subtypes
  datas = @elements.values
  datas << @default_element if @default_element
  return datas.map { |data|
    data[:type].is_a?(Hash) ? data[:type].first : data[:type]
  }.flatten.select { |c| c.is_a?(Class) && c.include?(Structured) }.uniq
end

#template(indent: '') ⇒ Object

Produces a template YAML file for this Structured object.



768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
# File 'lib/structured.rb', line 768

def template(indent: '')
  res = "#{indent}# #{name}\n"
  if @class_description
    res << TextTools.line_break(@class_description, prefix: "#{indent}# ")
    res << "\n"
  end

  in_opt = false
  max_len = @elements.keys.map { |e| e.to_s.length }.max

  each_element do |elt, data|
    next if data[:optional] == :omit
    if data[:optional] && !in_opt
      res << "#{indent}#\n#{indent}# Optional\n"
      in_opt = true
    end

    res << "#{indent}#{elt}:"
    spacing = ' ' * (max_len - elt.to_s.length + 1)
    if data[:default]
      res << spacing << data[:default].inspect << "\n"
    else
      res << template_type(data[:type], indent, spacing)
    end
  end
  return res
end

#template_type(type, indent, sp = ' ') ⇒ Object

Parameters:

  • type

    The Structured data type specification.

  • indent

    The indent string before new lines.

  • sp (defaults to: ' ')

    Spacing after the colon, if any.



800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
# File 'lib/structured.rb', line 800

def template_type(type, indent, sp = ' ')
  res = String.new('')
  case type
  when :boolean
    res << " true/false\n"
  when Class
    if type == String
      res << "#{sp}\"\"\n"
    elsif type.include?(Structured)
      res << "\n" << type.template(indent: indent + '  ')
    else
      res << "#{sp}# #{type}\n"
    end
  when Array
    if type.first == String
      res << "#{sp}[ \"\", ... ]\n"
    else
      res << "\n#{indent}  -" << template_type(type.first, indent + '  ')
    end
  when Hash
    if type.first.first == String
      res << "\n#{indent}  \"\":"
    else
      res << "\n#{indent}  [#{type.first.first}]:"
    end
    res << template_type(type.first.last, indent + '  ')
  end
  return res
end

#try_autoconvert(type, item) ⇒ Object

Several types can be automatically converted:

  • Symbol into String

  • String into Regexp



651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
# File 'lib/structured.rb', line 651

def try_autoconvert(type, item)

  case { item.class => type }

  when { Symbol => String }
    return item.to_s

  when { String => Symbol }
    return item.to_sym

  when { String => Regexp }
    begin
      return Regexp.new(item)
    rescue RegexpError
      input_err("#{item} is not a valid regular expression")
    end

  when { String => Date }
    begin
      return Date.parse(item)
    rescue Date::Error
      input_err("#{item} is not a valid date")
    end
  end

  return item
end

#try_read_array(filenames) ⇒ Object



508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
# File 'lib/structured.rb', line 508

def try_read_array(filenames)
  new_item = []
  begin
    filenames.each do |file|
      begin
        res = YAML.load_file(file)
        raise InputError unless res.is_a?(Array)
        new_item.concat(res)
      rescue
        input_err("Failed to read array from #{file}: #$!")
      end
    end
  end
  return new_item
end

#try_read_file(hash) ⇒ Object

If the hash contains a key :read_file, then try reading a file containing additional keys, and return a new hash merging the two. This will not work recursively; the input file may not further contain a :read_file key.

If the given hash and the :read_file hash contain duplicate keys, the given hash overrides the file values.



495
496
497
498
499
500
501
502
503
504
505
506
# File 'lib/structured.rb', line 495

def try_read_file(hash)
  file = hash['read_file'] || hash[:read_file]
  return hash unless file
  begin
    res = YAML.load_file(file).merge(hash)
    res.delete('read_file')
    res.delete(:read_file)
    return res
  rescue
    input_err("Failed to read Structured YAML input from #{file}: #$!")
  end
end

#try_run(block, obj, val, err_name) ⇒ Object



582
583
584
585
586
587
588
# File 'lib/structured.rb', line 582

def try_run(block, obj, val, err_name)
  begin
    val = obj.instance_exec(val, &block)
  rescue StandardError => e
    input_err("#{err_name} failed: #{e.to_s}")
  end
end