lib/io_streams/tabular/parser/fixed.rb in iostreams-1.2.1 vs lib/io_streams/tabular/parser/fixed.rb in iostreams-1.3.0

- old
+ new

@@ -1,68 +1,173 @@ module IOStreams class Tabular module Parser # Parsing and rendering fixed length data class Fixed < Base - attr_reader :fixed_layout + attr_reader :layout, :truncate # Returns [IOStreams::Tabular::Parser] # # Parameters: # layout: [Array<Hash>] # [ - # {key: 'name', size: 23 }, - # {key: 'address', size: 40 }, - # {key: 'zip', size: 5 } + # {size: 23, key: "name"}, + # {size: 40, key: "address"}, + # {size: 2}, + # {size: 5, key: "zip"}, + # {size: 8, key: "age", type: :integer}, + # {size: 10, key: "weight", type: :float, decimals: 2} # ] - def initialize(layout:) - @fixed_layout = parse_layout(layout) + # + # Notes: + # * Leave out the name of the key to ignore that column during parsing, + # and to space fill when rendering. For example as a filler. + # + # Types: + # :string + # This is the default type. + # Applies space padding and the value is left justified. + # Returns value as a String + # :integer + # Applies zero padding to the left. + # Returns value as an Integer + # Raises Errors::ValueTooLong when the supplied value cannot be rendered in `size` characters. + # :float + # Applies zero padding to the left. + # Returns value as a float. + # The :size is the total size of this field including the `.` and the decimals. + # Number of :decimals + # Raises Errors::ValueTooLong when the supplied value cannot be rendered in `size` characters. + def initialize(layout:, truncate: true) + @layout = Layout.new(layout) + @truncate = truncate end + # The required line length for every fixed length line + def line_length + layout.length + end + # Returns [String] fixed layout values extracted from the supplied hash. - # String will be encoded to `encoding` + # + # Notes: + # * A nil value is considered an empty string + # * When a supplied value exceeds the column size it is truncated. def render(row, header) hash = header.to_hash(row) result = "" - fixed_layout.each do |map| - # A nil value is considered an empty string - value = hash[map.key].to_s - result << format("%-#{map.size}.#{map.size}s", value) + layout.columns.each do |column| + value = hash[column.key].to_s + if !truncate && (value.length > column.size) + raise(Errors::ValueTooLong, "Value: #{value.inspect} is too long to fit into column #{column.key} of size #{column.size}") + end + + result << column.render(value) end result end # Returns [Hash<Symbol, String>] fixed layout values extracted from the supplied line. # String will be encoded to `encoding` def parse(line) unless line.is_a?(String) - raise(IOStreams::Errors::TypeMismatch, "Format is :fixed. Invalid parse input: #{line.class.name}") + raise(Errors::TypeMismatch, "Line must be a String when format is :fixed. Actual: #{line.class.name}") end + if line.length != layout.length + raise(Errors::InvalidLineLength, "Expected line length: #{layout.length}, actual line length: #{line.length}") + end + hash = {} index = 0 - fixed_layout.each do |map| - value = line[index..(index + map.size - 1)] - index += map.size - hash[map.key] = value.to_s.strip + layout.columns.each do |column| + # Ignore "columns" that have no keys. E.g. Fillers + hash[column.key] = column.parse(line[index, column.size]) if column.key + index += column.size end hash end private - FixedLayout = Struct.new(:key, :size) + class Layout + attr_reader :columns, :length - # Returns [Array<FixedLayout>] the layout for this fixed width file. - # Also validates values - def parse_layout(layout) - layout.collect do |map| - size = map[:size] - key = map[:key] - raise(ArgumentError, "Missing required :key and :size in: #{map.inspect}") unless size && key + # Returns [Array<FixedLayout>] the layout for this fixed width file. + # Also validates values + def initialize(layout) + @length = 0 + @columns = parse_layout(layout) + end - FixedLayout.new(key, size) + private + + def parse_layout(layout) + @length = 0 + layout.collect do |hash| + raise(Errors::InvalidLayout, "Missing required :size in: #{hash.inspect}") unless hash.key?(:size) + + column = Column.new(**hash) + @length += column.size + column + end + end + end + + class Column + TYPES = %i[string integer float].freeze + + attr_reader :key, :size, :type, :decimals + + def initialize(key: nil, size:, type: :string, decimals: 2) + @key = key + @size = size.to_i + @type = type.to_sym + @decimals = decimals + + raise(Errors::InvalidLayout, "Size #{size.inspect} must be positive") unless @size.positive? + raise(Errors::InvalidLayout, "Unknown type: #{type.inspect}") unless TYPES.include?(type) + end + + def parse(value) + return if value.nil? + + stripped_value = value.to_s.strip + + case type + when :string + stripped_value + when :integer + stripped_value.length.zero? ? nil : value.to_i + when :float + stripped_value.length.zero? ? nil : value.to_f + else + raise(Errors::InvalidLayout, "Unsupported type: #{type.inspect}") + end + end + + def render(value) + case type + when :string + format("%-#{size}.#{size}s", value.to_s) + when :integer + formatted = format("%0#{size}d", value.to_i) + if formatted.length > size + raise(Errors::ValueTooLong, "Value: #{value} is too large to fit into column:#{key} of size:#{size}") + end + + formatted + when :float + formatted = format("%0#{size}.#{decimals}f", value.to_f) + if formatted.length > size + raise(Errors::ValueTooLong, "Value: #{value} is too large to fit into column:#{key} of size:#{size}") + end + + formatted + else + raise(Errors::InvalidLayout, "Unsupported type: #{type.inspect}") + end end end end end end