diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index fd3d94c07..8c7ca5826 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -30,6 +30,7 @@ These changes are merged into the `main` branch, but have not been released. Aft === Breaking === Added +* `Fraction::ToDecimal` transform (and supporting `Utils::ExtractFractions` and `Data::ConvertibleFraction` classes) (PR#108) * `yardspec` gem to support running YARD examples as RSpec tests (PR#107) * Branch coverage to `simplecov` setup (PR#107) diff --git a/lib/kiba/extend/data/convertible_fraction.rb b/lib/kiba/extend/data/convertible_fraction.rb new file mode 100644 index 000000000..59de928a4 --- /dev/null +++ b/lib/kiba/extend/data/convertible_fraction.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Kiba + module Extend + module Data + # Value object encoding an extracted string fraction (e.g. '1 1/2') so it can be converted. + # + # Can represent invalid/non-convertible "fractions" + class ConvertibleFraction + include Comparable + + attr_reader :whole, :fraction, :position + + # @param whole [Integer] whole number preceding a fraction + # @param fraction [String] + # @param position [Range] indicates position of fractional data within original string + def initialize(whole: 0, fraction:, position:) + fail(TypeError, '`whole` must be an Integer') unless whole.is_a?(Integer) + fail(TypeError, '`position` must be a Range') unless position.is_a?(Range) + @whole = whole.freeze + @fraction = fraction.freeze + @position = position.freeze + end + + # @param val [String] the value in which textual fraction will be replaced with a decimal + # @param places [Integer] maximum number of decimal places to keep in the resulting decimal value + # @return [String] + def replace_in(val:, places: 4) + return val unless convertible? + + [prefix ? val[prefix] : '', to_s(places), val[suffix]].compact.join + end + + # @return [Float] + def to_f + return nil unless convertible? + + ( Rational(fraction) + whole ).to_f + end + + # @param places [Integer] + # @return [String] + def to_s(places = 4) + return nil unless convertible? + + ( Rational(fraction) + whole ).round(+places).to_f.to_s + end + + # @return [Boolean] whether the fraction is indeed convertible + def convertible? + Rational(fraction) + rescue ZeroDivisionError + false + else + true + end + + def ==(other) + whole == other.whole && fraction == other.fraction && position == other.position + end + alias_method :eql?, :== + + def <=>(other) + position.first <=> other.position.first + end + + def hash + [self.class, whole, fraction, position].hash + end + + def to_h + {whole: whole, fraction: fraction, position: position} + end + + private + + def prefix + return nil if position.min == 0 + + 0..position.min - 1 + end + + def suffix + position.max + 1..-1 + end + + end + end + end +end + diff --git a/lib/kiba/extend/transforms/fraction.rb b/lib/kiba/extend/transforms/fraction.rb new file mode 100644 index 000000000..7dce99287 --- /dev/null +++ b/lib/kiba/extend/transforms/fraction.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Kiba + module Extend + module Transforms + # Transforms to deal with fractions in field values + module Fraction + ::Fraction = Kiba::Extend::Transforms::Fraction + end + end + end +end diff --git a/lib/kiba/extend/transforms/fraction/to_decimal.rb b/lib/kiba/extend/transforms/fraction/to_decimal.rb new file mode 100644 index 000000000..d371ff459 --- /dev/null +++ b/lib/kiba/extend/transforms/fraction/to_decimal.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +module Kiba + module Extend + module Transforms + module Fraction + # Converts fractions expressed like "1 1/4" to decimals like "1.25" + # + # @example Defaults and general behavior/value handling + # # Used in pipeline as: + # # transform Fraction::ToDecimal, fields: :dim + # xform = Fraction::ToDecimal.new(fields: :dim) + # input = [ + # {dim: nil}, + # {dim: ''}, + # {dim: 'foo'}, + # {dim: '1/2'}, + # {dim: '6-1/4 x 9-1/4'}, + # {dim: '10 5/8x13'}, + # {dim: '1 2/3 x 5 1/2'}, + # {dim: 'approximately 2/3 by 1/2in (height unknown)'} + # ] + # result = input.map{ |row| xform.process(row) } + # expected = [ + # {dim: nil}, + # {dim: ''}, + # {dim: 'foo'}, + # {dim: '0.5'}, + # {dim: '6.25 x 9.25'}, + # {dim: '10.625x13'}, + # {dim: '1.6667 x 5.5'}, + # {dim: 'approximately 0.6667 by 0.5in (height unknown)'} + # ] + # expect(result).to eq(expected) + # + # @example Multiple fields and targets + # # Used in pipeline as: + # # transform Fraction::ToDecimal, fields: %i[w h], targets: %i[width height] + # xform = Fraction::ToDecimal.new(fields: %i[w h], targets: %i[width height]) + # input = [{w: '8 1/2', h: '11'}] + # result = input.map{ |row| xform.process(row) } + # expected = [{w: '8 1/2', h: '11', width: '8.5', height: '11'}] + # expect(result).to eq(expected) + # + # @example Multiple fields and targets, and `delete_sources` = true + # # Used in pipeline as: + # # transform Fraction::ToDecimal, fields: %i[w h], targets: %i[w height], delete_sources: true + # xform = Fraction::ToDecimal.new(fields: %i[w h], targets: %i[w height], delete_sources: true) + # input = [{w: '8 1/2', h: '11'}] + # result = input.map{ |row| xform.process(row) } + # expected = [{w: '8.5', height: '11'}] + # expect(result).to eq(expected) + # + # @example `target_format: :float` and `places: 2` + # # Used in pipeline as: + # # transform Fraction::ToDecimal, fields: :w, target_format: :float, places: 2 + # xform = Fraction::ToDecimal.new(fields: :w, target_format: :float, places: 2) + # input = [ + # {w: '8-2/3'}, + # {w: '2/3 in'} + # ] + # result = input.map{ |row| xform.process(row) } + # expected = [ + # {w: 8.67}, + # {w: '0.67 in'} + # ] + # expect(result).to eq(expected) + # + # @example `target_format: :float, places: 2, whole_fraction_sep: [' ']` + # # Used in pipeline as: + # # transform Fraction::ToDecimal, + # # fields: :w, + # # target_format: :float, + # # places: 2, + # # whole_fraction_sep: [' '] + # xform = Fraction::ToDecimal.new( + # fields: :w, target_format: :float, places: 2, whole_fraction_sep: [' '] + # ) + # input = [ + # {w: '8-2/3'}, + # {w: '2/3 in'} + # ] + # result = input.map{ |row| xform.process(row) } + # expected = [ + # {w: '8-0.67'}, + # {w: '0.67 in'} + # ] + # expect(result).to eq(expected) + class ToDecimal + # @param fields [Symbol, Array(Symbol)] Source data fields. If no targets given, converted values are + # written back into the original fields. + # @param targets [nil, Symbol, Array(Symbol)] Target data fields, if different from source data fields. + # If `targets` are specified at all, a target must be specified for each value in `fields`. The target + # for a given field can be the same as the given field, however. + # @param target_format [:string, :float] If fractions are being extracted from longer text strings and replaced, + # this should always be `:string`. Likewise if there may be more than one fraction in a given field value. + # This is the usual expected case. If the source data fields are known to only contain single fraction + # values and you need to use them in calculations in subsequent transforms within the same job, you may + # wish to set this as `:float` for greater accuracy. + # @param places [Integer] Number of decimal places. Applied if `target_format` = `:string` + # @param whole_fraction_sep [Array(String)] List of characters that precede a fraction after a whole + # number, indicating that the whole number and fraction should be extracted together. + # See {Utils::ExtractFractions} for further explanation. + # @param delete_sources [Boolean] If `targets` are given, `fields` are deleted from row. Has no effect + # if no `targets` are given, or if the target for a field equals the field. + def initialize(fields:, + targets: nil, + target_format: :string, + places: 4, + whole_fraction_sep: [' ', '-'], + delete_sources: false) + @fields = [fields].flatten + @targets = targets ? [targets].flatten : nil + @target_format = target_format + @places = places + @delete_sources = delete_sources + @extractor = Kiba::Extend::Utils::ExtractFractions.new(whole_fraction_sep: whole_fraction_sep) + end + + # @param row [Hash{ Symbol => String }] + def process(row) + fields.each{ |field| to_decimal(field, row) } + delete_source_fields(row) + row + end + + private + + attr_reader :fields, :targets, :target_format, :places, :delete_sources, :extractor + + def delete_source_fields(row) + return unless delete_sources && targets + + fields.each_with_index do |field, ind| + row.delete(field) unless targets[ind] == field + end + end + + def floatable?(value) + true unless value.match?(/[^0-9.]/) + end + + def format_field_value(value) + return value unless target_format == :float && floatable?(value) + + value.to_f + end + + def replace_fractions(fractions, value) + val = value.dup + fractions.each do |fraction| + val = fraction.replace_in(val: val, places: places) + end + val + end + + def to_decimal(field, row) + targetfield = target(field) + fieldval = row[field] + row[targetfield] = fieldval + return if fieldval.blank? + + fractions = extractor.call(fieldval) + return if fractions.empty? + + replaced = replace_fractions(fractions, fieldval) + formatted = format_field_value(replaced) + + row[targetfield] = formatted + end + + def target(srcfield) + return srcfield unless targets + + ind = fields.find_index(srcfield) + targets[ind] + end + end + end + end + end +end diff --git a/lib/kiba/extend/utils/extract_fractions.rb b/lib/kiba/extend/utils/extract_fractions.rb new file mode 100644 index 000000000..cdb4c560a --- /dev/null +++ b/lib/kiba/extend/utils/extract_fractions.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'strscan' + +module Kiba + module Extend + module Utils + # Extracts {Data::ConvertibleFractions} from given String and returns only fractions that can be + # converted to decimal, in the order they will need to be replaced in the string + class ExtractFractions + # @param whole_fraction_sep [Array(String)] List of characters that precede a fraction after a whole + # number, indicating that the whole number and fraction should be extracted together. If this is + # set to `[' ', '-']` (the default), then both `1 1/2` and `1-1/2` will be extracted with `1` as + # the whole number and `1/2` as the fraction, and converted to `1.5`. If this is set to `[' ']`, + # then `1 1/2` will be extracted as described preveiously. For `1-1/2`, no whole number value + # will be extracted. `1/2` will be extracted as the fraction, and it will be converted to '0.5'. + def initialize(whole_fraction_sep: [' ', '-']) + @whole_fraction_sep = whole_fraction_sep + @fpattern = /(\d+\/\d+)/ + @fraction = Kiba::Extend::Data::ConvertibleFraction + end + + # @param value [String] + def call(value) + return [] unless value.match?(fpattern) + + result = [] + scanner = StringScanner.new(value) + scan(scanner, result) + result.each do |fraction| + unless fraction.convertible? + warn("#{self.class.name}: Unconvertible fraction: #{value[fraction.position]}") + end + end + result.sort.reverse + end + + private + + attr_reader :fpattern, :whole_fraction_sep, :fraction + + def extract_fraction(scanner, result) + startpos = scanner.pos + scanner.scan(fpattern) + result << fraction.new(**{fraction: scanner.captures[0], position: startpos..scanner.pos - 1 }) + end + + def try_whole_fraction_extract(scanner, result) + startpos = scanner.pos + whole_num = scanner.scan(/\d+/).to_i + sep = scanner.scan(/./) + fmatch = scanner.match?(fpattern) + if whole_fraction_sep.any?(sep) && fmatch + result << fraction.new(**{whole: whole_num, fraction: scanner.scan(fpattern), position: startpos..scanner.pos - 1 }) + end + end + + def scan(scanner, result) + return if scanner.eos? + return if scanner.rest_size < 3 + return unless scanner.exist?(fpattern) + + scan_next(scanner, result) + end + + def scan_next(scanner, result) + scanner.skip(/\D+/) + if scanner.match?(fpattern) + extract_fraction(scanner, result) + else + try_whole_fraction_extract(scanner, result) + end + scan(scanner, result) + end + end + end + end +end diff --git a/spec/kiba/extend/data/convertible_fraction_spec.rb b/spec/kiba/extend/data/convertible_fraction_spec.rb new file mode 100644 index 000000000..862e4078d --- /dev/null +++ b/spec/kiba/extend/data/convertible_fraction_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Kiba::Extend::Data::ConvertibleFraction do + subject(:klass){ described_class } + + describe '.initialize' do + let(:result){ klass.new(**params) } + + context 'when `whole` is not an Integer' do + let(:params){ {whole: '1', fraction: '3/4', position: 2..4} } + + it 'raises TypeError' do + expect{ result }.to raise_error(TypeError, '`whole` must be an Integer') + end + end + + context 'when `position` is not a Range' do + let(:params){ {whole: 1, fraction: '3/4', position: 2} } + + it 'raises TypeError' do + expect{ result }.to raise_error(TypeError, '`position` must be a Range') + end + end + end + + + describe '#to_h' do + it 'returns Hash' do + cf = klass.new(fraction: '1/2', position: 0..2) + expected = {whole: 0, fraction: '1/2', position: 0..2} + expect(cf.to_h).to eq(expected) + end + end + + describe '#replace_in' do + let(:expectations) do + { + [{whole: 0, fraction: '2/3', position: 6..8}, + 'about 2/3 inch'] => 'about 0.6667 inch', + [{whole: 0, fraction: '1/2', position: 0..2}, + '1/2 inch'] => '0.5 inch', + [{whole: 0, fraction: '1/2', position: 6..8}, + 'about 1/2'] => 'about 0.5', + [{whole: 0, fraction: '1/0', position: 6..8}, + 'about 1/0 inch'] => 'about 1/0 inch' + } + end + let(:results) do + expectations.keys.map{ |arr| klass.new(**arr[0]).replace_in(val: arr[1]) } + end + let(:expected){ expectations.values } + + it 'behaves as expected' do + expect(results).to eq(expected) + end + end + + describe '#to_f' do + let(:expectations) do + { + {whole: 0, fraction: '1/2', position: 0..2} => 0.5, + {whole: 1, fraction: '3/4', position: 0..2} => 1.75, + {whole: 1, fraction: '3/0', position: 0..2} => nil, + } + end + let(:results) do + expectations.keys.map{ |params| klass.new(**params).to_f } + end + let(:expected){ expectations.values } + + it 'behaves as expected' do + expect(results).to eq(expected) + end + end + + describe '#to_s' do + let(:expectations) do + { + {whole: 0, fraction: '1/2', position: 0..2} => '0.5', + {whole: 1, fraction: '3/4', position: 0..2} => '1.75', + {whole: 1, fraction: '3/0', position: 0..2} => nil, + {whole: 1, fraction: '2/3', position: 0..2} => '1.6667', + } + end + let(:results) do + expectations.keys.map{ |params| klass.new(**params).to_s } + end + let(:expected){ expectations.values } + + it 'behaves as expected' do + expect(results).to eq(expected) + end + + context 'with places: 1' do + let(:expectations) do + { + {whole: 0, fraction: '1/2', position: 0..2} => '0.5', + {whole: 1, fraction: '3/4', position: 0..2} => '1.8', + {whole: 1, fraction: '3/0', position: 0..2} => nil, + {whole: 1, fraction: '2/3', position: 0..2} => '1.7', + } + end + let(:results) do + expectations.keys.map{ |params| klass.new(**params).to_s(1) } + end + let(:expected){ expectations.values } + + it 'behaves as expected' do + expect(results).to eq(expected) + end + + end + end + + describe '#convertible?' do + let(:expectations) do + { + {whole: 0, fraction: '1/2', position: 0..2} => true, + {whole: 0, fraction: '1/0', position: 0..2} => false + } + end + let(:results) do + expectations.keys.map{ |params| klass.new(**params).convertible? } + end + let(:expected){ expectations.values } + + it 'behaves as expected' do + expect(results).to eq(expected) + end + end + + describe '#==' do + let(:expectations) do + { + [{whole: 0, fraction: '1/2', position: 0..2}, + {fraction: '1/2', position: 0..2}] => true, + [{whole: 1, fraction: '1/2', position: 0..2}, + {whole: 0, fraction: '1/2', position: 0..2}] => false + } + end + let(:results) do + expectations.keys.map{ |pair| klass.new(**pair[0]) == klass.new(**pair[1]) } + end + let(:expected){ expectations.values } + + it 'behaves as expected' do + expect(results).to eq(expected) + end + end + + describe '#<=>' do + it 'behaves as expected' do + arr = [ + klass.new(fraction: '1/2', position: 27..29), + klass.new(fraction: '1/2', position: 0..2), + klass.new(fraction: '1/2', position: 5..7), + klass.new(fraction: '1/2', position: 100..102), + ] + expected = [0..2, 5..7, 27..29, 100..102] + expect(arr.sort.map(&:position)).to eq(expected) + end + end + + describe '#hash' do + let(:expectations) do + { + [{whole: 0, fraction: '1/2', position: 0..2}, + {whole: 0, fraction: '1/2', position: 0..2}] => true, + [{whole: 1, fraction: '1/2', position: 0..2}, + {whole: 0, fraction: '1/2', position: 0..2}] => false + } + end + let(:results) do + expectations.keys.map{ |pair| klass.new(**pair[0]).hash == klass.new(**pair[1]).hash } + end + let(:expected){ expectations.values } + + it 'behaves as expected' do + expect(results).to eq(expected) + end + end +end diff --git a/spec/kiba/extend/utils/extract_fractions_spec.rb b/spec/kiba/extend/utils/extract_fractions_spec.rb new file mode 100644 index 000000000..ad786d15b --- /dev/null +++ b/spec/kiba/extend/utils/extract_fractions_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +def fraction(params) + Kiba::Extend::Data::ConvertibleFraction.new(**params) +end + +RSpec.describe Kiba::Extend::Utils::ExtractFractions do + subject(:xform){ described_class.new(**params) } + + # describe '.initialize' do + # context 'with defaults' do + # let(:params){ {} } + + # it 'has expected instance variables' do + # expect + # end + # end + # end + + describe '#call' do + let(:result){ xform.call(val) } + let(:results){ expectations.keys.map{ |val| xform.call(val) } } + let(:expected){ expectations.values } + + context %{with defaults} do + let(:params){ {} } + let(:expectations) do + { + '2/3 x 9-7/8 x 1/4, 20, 3 3/4' => [ + fraction({whole: 3, fraction: '3/4', position: 23..27}), + fraction({fraction: '1/4', position: 14..16}), + fraction({whole: 9, fraction: '7/8', position: 6..10}), + fraction({fraction: '2/3', position: 0..2}) + ], + '6-1/2 x 9-1/4 and height unknown' => [ + fraction({whole: 9, fraction: '1/4', position: 8..12}), + fraction({whole: 6, fraction: '1/2', position: 0..4}) + ], + '123' => [], + 'measures 1/4ft' => [ + fraction({fraction: '1/4', position: 9..11}) + ], + '7/16-1/4' => [ + fraction({fraction: '1/4', position: 5..7}), + fraction({fraction: '7/16', position: 0..3}) + ] + } + end + + it 'returns expected' do + expect(results).to eq(expected) + end + end + + context %{with only ' ' as whole_fraction_sep} do + let(:params){ {whole_fraction_sep: [' ']} } + let(:expectations) do + { + '2/3 x 9-7/8 x 1/4, 20, 3 3/4' => [ + fraction({whole: 3, fraction: '3/4', position: 23..27}), + fraction({fraction: '1/4', position: 14..16}), + fraction({fraction: '7/8', position: 8..10}), + fraction({fraction: '2/3', position: 0..2}) + ], + '6-2/3 x 9-1/4 and height unknown' => [ + fraction({fraction: '1/4', position: 10..12}), + fraction({fraction: '2/3', position: 2..4}), + ], + '123' => [], + 'measures 1/4ft' => [ + fraction({fraction: '1/4', position: 9..11}) + ], + '7/16-1/4' => [ + fraction({fraction: '1/4', position: 5..7}), + fraction({fraction: '7/16', position: 0..3}) + ] + } + end + + it 'returns expected' do + expect(results).to eq(expected) + end + end + + context %{with un-convertable "fraction"} do + let(:params){ {} } + let(:value){ 'copy 1/0' } + let(:result){ xform.call(value) } + it 'returns expected' do + msg = 'Kiba::Extend::Utils::ExtractFractions: Unconvertible fraction: 1/0' + expect(xform).to receive(:warn).with(msg) + expect(result).to eq([fraction({fraction: '1/0', position: 5..7})]) + end + end + end +end