Skip to content

Commit

Permalink
Merge pull request #39 from yast/hash-collections
Browse files Browse the repository at this point in the history
Hash collections
  • Loading branch information
imobachgs committed Jan 23, 2019
2 parents 123b186 + edb8bc9 commit 7c679c4
Show file tree
Hide file tree
Showing 8 changed files with 319 additions and 44 deletions.
35 changes: 32 additions & 3 deletions src/lib/y2configuration_management/salt/form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,9 @@ def initialize(id, spec, parent:)
#
# @return [String]
def locator
return FormElementLocator.new([id]) if parent.nil?
return FormElementLocator.new([id.to_sym]) if parent.nil?
return parent.locator if parent.is_a?(Collection)
parent.locator.join(id)
parent.locator.join(id.to_sym)
end

private
Expand Down Expand Up @@ -187,6 +187,16 @@ def find_element_by(arg)
return self if arg.any? { |k, v| public_send(k) == v }
nil
end

# Determines whether the input is a collection key
#
# In hash based collections, there is an special attribute called `$key` whose value is used
# as collection index.
#
# @return [Boolean]
def collection_key?
id == "$key"
end
end

# Container Element
Expand Down Expand Up @@ -228,10 +238,21 @@ def find_element_by(arg)

# @param spec [Hash] form element specification
def build_elements(spec)
spec.select { |k, _v| !k.start_with?("$") }.each do |id, nested_spec|
form_elements_in(spec).each do |id, nested_spec|
@elements << FormElementFactory.build(id, nested_spec, parent: self)
end
end

# Determines which part of the given spec refers to a form element
#
# Usually, all elements whose name starts with `$` are supposed to be metadata, except the
# special `$key` element which is considered an form input.
#
# @param spec [Hash] form element specification
# @return [Array<Hash>]
def form_elements_in(spec)
spec.select { |k, _v| !k.start_with?("$") || k == "$key" }
end
end

# Defines a collection of {FormElement}s or {Container}s all of them based in
Expand Down Expand Up @@ -281,6 +302,14 @@ def find_element_by(arg)
nil
end

# Determines whether the collection is indexed by a key (instead of a numeric index)
#
# @return [Boolean] true if the collection uses a key; false otherwise
def keyed?
return false if prototype.nil? || !prototype.respond_to?(:elements)
prototype.elements.any? { |e| e.respond_to?(:collection_key?) && e.collection_key? }
end

private

# Return a single or group of {FormElement}s based on the prototype given
Expand Down
138 changes: 121 additions & 17 deletions src/lib/y2configuration_management/salt/form_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,32 +50,32 @@ def get(locator)

# Updates an element's value
#
# @param locator [String] Locator of the collection
# @param value [Object] New value
# @param locator [FormElementLocator] Locator of the collection
# @param value [Object] New value
def update(locator, value)
parent = get(locator.parent)
parent[locator.last] = value
parent[key_for(locator.last)] = value
end

# Adds an element to a collection
#
# @param locator [String] Locator of the collection
# @param value [Hash] Value to add
# @param locator [FormElementLocator] Locator of the collection
# @param value [Object] Value to add
def add_item(locator, value)
collection = get(locator)
collection.push(value)
end

# @param locator [String] Locator of the collection
# @param value [Object] New value
# @param locator [FormElementLocator] Locator of the collection
# @param value [Object] New value
def update_item(locator, value)
collection = get(locator.parent)
collection[locator.last] = value
collection[key_for(locator.last)] = value
end

# Removes an element from a collection
#
# @param locator [String] Locator of the collection
# @param locator [FormElementLocator] Locator of the collection
def remove_item(locator)
collection = get(locator.parent)
collection.delete_at(locator.last)
Expand All @@ -85,14 +85,32 @@ def remove_item(locator)
#
# @return [Hash]
def to_h
@data
data_for_pillar(@data)
end

private

# Recursively finds a value
#
# @param data [Hash,Array] Data structure to search for the value
# @param locator [FormElementLocator] Value locator
# @return [Object] Value
def find_by_locator(data, locator)
return nil if data.nil?
return data if locator.first.nil?
key = locator.first
next_data =
if key.is_a?(String)
data.find { |e| e["$key"] == key }
else
data[key_for(key)]
end
find_by_locator(next_data, locator.rest)
end

# Default value for a given element
#
# @param locator [String] Element locator
# @param locator [FormElementLocator] Element locator
def default_for(locator)
element = form.find_element_by(locator: locator)
element ? element.default : nil
Expand All @@ -117,20 +135,106 @@ def data_for_element(element, data)
defaults = element.elements.reduce({}) { |a, e| a.merge(data_for_element(e, data)) }
{ element.id => defaults }
else
value = find_by_locator(data, element.locator.rest) # FIXME: remove '.root'
{ element.id => value.nil? ? element.default : value }
value = find_in_pillar_data(data, element.locator.rest) # FIXME: remove '.root'
value ||= element.default
{ element.id => data_from_pillar_collection(value, element) }
end
end

# Finds a value
# Converts from a Pillar collection into form data
#
# @param data [Hash,Array] Data structure to search for the value
# Basically, a collection might be an array or a hash. The internal representation, however,
# is always an array, so it is needed to do the conversion.
#
# @param element [Y2ConfigurationManagement::Salt::FormElement]
# @param value [Array,Hash]
# @return
def data_from_pillar_collection(collection, element)
return nil if collection.nil?
return collection unless element.respond_to?(:keyed?) && element.keyed?
collection.map do |k, v|
{ "$key" => k }.merge(v)
end
end

# Finds a value within a Pillar
#
# @todo This API might be available through the Pillar class.
#
# @param data [Hash,Array] Data structure from the Pillar
# @param locator [FormElementLocator] Value locator
# @return [Object] Value
def find_by_locator(data, locator)
def find_in_pillar_data(data, locator)
return nil if data.nil?
return data if locator.first.nil?
find_by_locator(data[locator.first], locator.rest)
key = locator.first
key = key.is_a?(Symbol) ? key.to_s : key
find_in_pillar_data(data[key], locator.rest)
end

# Returns data in a format to be used by the Pillar
#
# @param data [Object]
# @return [Object]
def data_for_pillar(data)
case data
when Array
collection_for_pillar(data)
when Hash
hash_for_pillar(data)
else
data
end
end

# Recursively converts a hash into one suitable to be used in a Pillar
#
# @param data [Hash]
# @return [Hash]
def hash_for_pillar(data)
data.reduce({}) do |all, (k, v)|
value = data_for_pillar(v)
next all if value.nil?
all.merge(k.to_s => value)
end
end

# Converts a collection to be used in a Pillar
#
# Arrays containing hashes with a `$key` element will be converted into a hash
# using the `$key` values as hash keys. See #hash_collection_for_pillar.
#
# @param collection [Array]
# @return [Array,Hash]
def collection_for_pillar(collection)
first = collection.first
return [] if first.nil?
if first.respond_to?(:key?) && first.key?("$key")
hash_collection_for_pillar(collection)
else
collection.map { |d| data_for_pillar(d) }
end
end

# Converts a collection into a hash to be used in a Pillar
#
# @param collection [Array<Hash>] This method expects an array containing hashes which include
# `$key` element.
# @return [Array,Hash]
def hash_collection_for_pillar(collection)
collection.reduce({}) do |all, item|
new_item = item.clone
key = new_item.delete("$key")
all.merge(key => data_for_pillar(new_item))
end
end

# Convenience method which converts a value to be used as key for a array or a hash
#
# @param [String,Symbol,Integer]
# @return [String,Integer]
def key_for(key)
key.is_a?(Symbol) ? key.to_s : key
end
end
end
Expand Down
41 changes: 31 additions & 10 deletions src/lib/y2configuration_management/salt/form_element_locator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,22 @@ module Y2ConfigurationManagement
module Salt
# Represent the locator to a form element
#
# The locator can be seen as a path to the form element.
# The locator can be seen as a path to the form element. In a human readable form, the locator
# looks like: ".root.person.computers[1]" or ".root.hosts[router]".
#
# @example Building a locator from a string
# @example Building a locator from a string for an array based collection
# locator = FormElementLocator.from_string(".root.person.computers[1]")
# locator.to_s #=> ".root.person.computers[1]"
# locator.parts #=> ["root", "person", "computers", 1]
# locator.parts #=> [:root, :person, :computers, 1]
#
# @example Building a locator from a string for a hash based collection
# locator = FormElementLocator.from_string(".root.hosts[router]")
# locator.to_s #=> ".root.hosts[router]"
# locator.parts #=> [:root, :hosts, "router"]
#
# @example Building a locator from its parts
# locator = FormElementLocator.new(:root, :hosts, "router")
# locator.to_s #=> ".root.hosts[router]"
class FormElementLocator
extend Forwardable

Expand All @@ -39,6 +49,8 @@ class FormElementLocator
class << self
# Builds a locator from a string
#
# @todo Support specifying dots within hash keys (e.g. `.hosts[download.opensuse.org]`).
#
# @param string [String] String representing an element locator
# @return [FormElementLocator]
def from_string(string)
Expand All @@ -51,25 +63,33 @@ def from_string(string)
private

# @return [Regexp] Regular expression representing a locator part
INDEXED_PART = /\A(\w+)\[(\d+)\]\z/
INDEXED_PART = /\A(\w+)\[(.+)\]\z/

# Parses a locator part
#
# @param string [String]
# @return [Array<String,Integer>] Locator subparts
# @return [Array<Integer,String,Symbol>] Locator subparts
def from_part(string)
match = INDEXED_PART.match(string)
return [string] unless match
[match[1], match[2].to_i]
return [string.to_sym] unless match
path, id = match[1..-1]
numeric_id?(id) ? [path.to_sym, id.to_i] : [path.to_sym, id]
end

# Determines whether the id is numeric or not
#
# @return [Boolean]
def numeric_id?(id)
id =~ /\A\d+\z/
end
end

# @return [Array<Integer,String>] Locator parts
# @return [Array<Integer,String,Symbol>] Locator parts
attr_reader :parts

# Constructor
#
# @param parts [Array<Integer,String>] Locator parts
# @param parts [Array<Integer,String,Symbol>] Locator parts
def initialize(parts)
@parts = parts
end
Expand Down Expand Up @@ -100,7 +120,8 @@ def to_s

# Extends a locator
#
# @param locators_or_parts [FormElementLocator,String,Integer] Parts or locators to join
# @param locators_or_parts [FormElementLocator,Integer,String,Symbol] Parts or locators
# to join
# @return [Locator] Augmented locator
def join(*locators_or_parts)
new_parts = locators_or_parts.reduce([]) do |all, item|
Expand Down
2 changes: 1 addition & 1 deletion src/lib/y2configuration_management/widgets/base_mixin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def initialize_base(spec)
#
# @return [FormElementLocator] Form element locator
def relative_locator
return parent.relative_locator.join(id) if respond_to?(:parent) && parent
return parent.relative_locator.join(id.to_sym) if respond_to?(:parent) && parent
Y2ConfigurationManagement::Salt::FormElementLocator.new([])
end
end
Expand Down
15 changes: 15 additions & 0 deletions test/fixtures/form.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,18 @@ person:
- SSD
size:
"$type": text
projects:
"$type": edit-group
"$minItems": 1
"$name": Project
"$itemName": ${i} project
"$default":
yast2:
url: https://yast.opensuse.org
"$prototype":
"$type": group
"$key":
"$type": text
"$name": Project name
url:
"$type": text

0 comments on commit 7c679c4

Please sign in to comment.