diff --git a/lib/bmg/reader/csv.rb b/lib/bmg/reader/csv.rb index 2b8d203..850a584 100644 --- a/lib/bmg/reader/csv.rb +++ b/lib/bmg/reader/csv.rb @@ -39,7 +39,9 @@ def to_s private def tuple(row) - row.to_hash.each_with_object({}){|(k,v),h| h[k.to_sym] = v } + row.to_hash.each_with_object({}){|(k,v),h| + h[k.to_sym] = v + } end def handle_type(type) diff --git a/lib/bmg/support/ordering.rb b/lib/bmg/support/ordering.rb index 0e99e68..e9d5bde 100644 --- a/lib/bmg/support/ordering.rb +++ b/lib/bmg/support/ordering.rb @@ -2,7 +2,13 @@ module Bmg class Ordering def initialize(attrs) - @attrs = attrs + @attrs = if attrs.empty? + [] + elsif attrs.first.is_a?(Symbol) + attrs.map{|a| [a, :asc] } + else + attrs + end end attr_reader :attrs @@ -33,5 +39,9 @@ def compare_attrs(t1, t2) 0 end + def to_a + attrs.to_a + end + end # class Ordering end # module Bmg diff --git a/lib/bmg/support/output_preferences.rb b/lib/bmg/support/output_preferences.rb index 394a6a0..052ec82 100644 --- a/lib/bmg/support/output_preferences.rb +++ b/lib/bmg/support/output_preferences.rb @@ -3,6 +3,7 @@ class OutputPreferences DEFAULT_PREFS = { attributes_ordering: nil, + tuple_ordering: nil, extra_attributes: :after } @@ -17,6 +18,12 @@ def self.dress(arg) new(arg) end + def tuple_ordering + return nil unless to = options[:tuple_ordering] + + @tuple_ordering = Ordering.new(to) + end + def attributes_ordering options[:attributes_ordering] end @@ -25,10 +32,29 @@ def extra_attributes options[:extra_attributes] end + def grouping_attributes + options[:grouping_attributes] + end + + def erase_redundance_in_group(before, current) + return [nil, current] unless ga = grouping_attributes + return [current, current] unless before + + new_before, new_current = current.dup, current.dup + ga.each do |attr| + return [new_before, new_current] unless before[attr] == current[attr] + new_current[attr] = nil + end + [new_before, new_current] + end + def order_attrlist(attrlist) return attrlist if attributes_ordering.nil? + index = Hash[attributes_ordering.each_with_index.to_a] - attrlist.sort{|a,b| + base = attrlist + base = attrlist & attributes_ordering if extra_attributes == :ignored + base.sort{|a,b| ai, bi = index[a], index[b] if ai && bi ai <=> bi diff --git a/lib/bmg/writer.rb b/lib/bmg/writer.rb index 02446cf..cfb9ad2 100644 --- a/lib/bmg/writer.rb +++ b/lib/bmg/writer.rb @@ -12,6 +12,17 @@ def infer_headers(from) attrlist ? output_preferences.order_attrlist(attrlist) : nil end + def each_tuple(relation, &bl) + if ordering = output_preferences.tuple_ordering + relation + .to_a + .sort{|t1,t2| ordering.compare_attrs(t1, t2) } + .each_with_index(&bl) + else + relation.each_with_index(&bl) + end + end + end # module Writer end # module Bmg require_relative 'writer/csv' diff --git a/lib/bmg/writer/csv.rb b/lib/bmg/writer/csv.rb index c42ce41..9fbfd04 100644 --- a/lib/bmg/writer/csv.rb +++ b/lib/bmg/writer/csv.rb @@ -16,12 +16,14 @@ def call(relation, string_or_io = nil) require 'csv' string_or_io, to_s = string_or_io.nil? ? [StringIO.new, true] : [string_or_io, false] headers, csv = infer_headers(relation.type), nil - relation.each do |tuple| + previous = nil + each_tuple(relation) do |tuple,i| if csv.nil? headers = infer_headers(tuple) if headers.nil? csv_opts = csv_options.merge(headers: headers) csv = CSV.new(string_or_io, **csv_opts) end + previous, tuple = output_preferences.erase_redundance_in_group(previous, tuple) csv << headers.map{|h| tuple[h] } end to_s ? string_or_io.string : string_or_io diff --git a/lib/bmg/writer/xlsx.rb b/lib/bmg/writer/xlsx.rb index 0789b03..9d565bc 100644 --- a/lib/bmg/writer/xlsx.rb +++ b/lib/bmg/writer/xlsx.rb @@ -6,11 +6,11 @@ class Xlsx DEFAULT_OPTIONS = { } - def initialize(csv_options, output_preferences = nil) - @csv_options = DEFAULT_OPTIONS.merge(csv_options) + def initialize(xlsx_options, output_preferences = nil) + @xlsx_options = DEFAULT_OPTIONS.merge(xlsx_options) @output_preferences = OutputPreferences.dress(output_preferences) end - attr_reader :csv_options, :output_preferences + attr_reader :xlsx_options, :output_preferences def call(relation, path) require 'write_xlsx' @@ -21,22 +21,24 @@ def call(relation, path) attr_reader :workbook, :worksheet def _call(relation, path) - @workbook = WriteXLSX.new(path) - @worksheet = workbook.add_worksheet + @workbook = xlsx_options[:workbook] || WriteXLSX.new(path) + @worksheet = xlsx_options[:worksheet] || workbook.add_worksheet headers = infer_headers(relation.type) - relation.each_with_index do |tuple,i| + before = nil + each_tuple(relation) do |tuple,i| headers = infer_headers(tuple) if headers.nil? headers.each_with_index do |h,i| worksheet.write_string(0, i, h) end if i == 0 + before, tuple = output_preferences.erase_redundance_in_group(before, tuple) headers.each_with_index do |h,j| meth, *args = write_pair(tuple[h]) worksheet.send(meth, 1+i, j, *args) end end - workbook.close + workbook.close unless xlsx_options[:workbook] path end diff --git a/spec/unit/support/test_ordering.rb b/spec/unit/support/test_ordering.rb index 62a2de7..af344ba 100644 --- a/spec/unit/support/test_ordering.rb +++ b/spec/unit/support/test_ordering.rb @@ -5,6 +5,18 @@ module Bmg Ordering.new(attrs) } + describe 'new' do + it 'supports pairs' do + o = Ordering.new([[:name, :desc],[:id, :asc]]) + expect(o.to_a).to eql([[:name, :desc],[:id, :asc]]) + end + + it 'supports attribute names' do + o = Ordering.new([:name, :id]) + expect(o.to_a).to eql([[:name, :asc],[:id, :asc]]) + end + end + context 'when a single attr, asc' do let(:attrs) { [[:title, :asc]] diff --git a/spec/unit/support/test_output_preferences.rb b/spec/unit/support/test_output_preferences.rb index 95a740d..a8688b5 100644 --- a/spec/unit/support/test_output_preferences.rb +++ b/spec/unit/support/test_output_preferences.rb @@ -1,61 +1,174 @@ require 'spec_helper' module Bmg - describe OutputPreferences, "order_attrlist" do + describe OutputPreferences do + describe "tuple_ordering" do + subject{ + OutputPreferences.new(options).tuple_ordering + } - subject{ - OutputPreferences.new(options).order_attrlist(list) - } + context 'with no tuple ordering' do + let(:options){{ + }} - context 'with no attribute ordering' do - let(:options){{ - }} - let(:list){ - [:firstname, :id, :lastname] - } + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'with some tuple ordering' do + let(:options){{ + tuple_ordering: [[:name, :desc]] + }} - it 'returns the list itself' do - expect(subject).to be(list) + it 'returns an Ordering instance' do + expect(subject).to be_a(Bmg::Ordering) + end end end - - context 'with a full list' do - let(:options){{ - attributes_ordering: [:id, :firstname, :lastname] - }} - let(:list){ - [:firstname, :id, :lastname] + describe "erase_redundance_in_group" do + subject { + OutputPreferences.new(options).erase_redundance_in_group(before, current) } - it 'returns an ordered list' do - expect(subject).to eql([:id, :firstname, :lastname]) + context 'with no group attributes' do + let(:options){{ + }} + let(:before){ nil } + let(:current){ { :name => "Bernard" } } + + it 'returns the current itself' do + expect(subject.first).to be(nil) + expect(subject.last).to be(current) + end end - end - context 'with a partial list and extra attributes after the others' do - let(:options){{ - attributes_ordering: [:firstname, :lastname], - extra_attributes: :after - }} - let(:list){ - [:id, :lastname, :firstname] - } + context 'with some group attributes' do + let(:options){{ + grouping_attributes: [:name, :reference] + }} + + context "when no before" do + let(:before){ nil } + let(:current){ { :name => "Bernard", :reference => "foo" } } + + it 'returns the current itself' do + expect(subject.first).to be(current) + expect(subject.last).to be(current) + end + end + + context "when a different before" do + let(:before){ { :name => "Yoann", :reference => "bar" } } + let(:current){ { :name => "Bernard", :reference => "foo" } } + + it 'returns the current itself' do + expect(subject.first).to eql(current) + expect(subject.last).to eql(current) + end + end + + context "within same full group" do + let(:before){ { :name => "Yoann", :reference => "bar", :extra => 1 } } + let(:current){ { :name => "Yoann", :reference => "bar", :extra => 2 } } + + it 'returns the current itself' do + expect(subject.first).to eql(current) + expect(subject.last).to eql({ :name => nil, :reference => nil, :extra => 2 }) + end + end + + context "within same first group" do + let(:before){ { :name => "Yoann", :reference => "bar", :extra => 1 } } + let(:current){ { :name => "Yoann", :reference => "foo", :extra => 2 } } - it 'returns an ordered list' do - expect(subject).to eql([:firstname, :lastname, :id]) + it 'returns the current itself' do + expect(subject.first).to eql(current) + expect(subject.last).to eql({ :name => nil, :reference => "foo", :extra => 2 }) + end + end + + context "within same second group but not first" do + let(:before){ { :name => "Yoann", :reference => "bar", :extra => 1 } } + let(:current){ { :name => "Bernard", :reference => "bar", :extra => 2 } } + + it 'returns the current itself' do + expect(subject.first).to eql(current) + expect(subject.last).to eql({ :name => "Bernard", :reference => "bar", :extra => 2 }) + end + end end end + describe "order_attrlist" do - context 'with a partial list and extra attributes before the others' do - let(:options){{ - attributes_ordering: [:firstname, :lastname], - extra_attributes: :before - }} - let(:list){ - [:id, :lastname, :firstname] + subject{ + OutputPreferences.new(options).order_attrlist(list) } - it 'returns an ordered list' do - expect(subject).to eql([:id, :firstname, :lastname]) + context 'with no attribute ordering' do + let(:options){{ + }} + let(:list){ + [:firstname, :id, :lastname] + } + + it 'returns the list itself' do + expect(subject).to be(list) + end + end + + context 'with a full list' do + let(:options){{ + attributes_ordering: [:id, :firstname, :lastname] + }} + let(:list){ + [:firstname, :id, :lastname] + } + + it 'returns an ordered list' do + expect(subject).to eql([:id, :firstname, :lastname]) + end + end + + context 'with a partial list and extra attributes after the others' do + let(:options){{ + attributes_ordering: [:firstname, :lastname], + extra_attributes: :after + }} + let(:list){ + [:id, :lastname, :firstname] + } + + it 'returns an ordered list' do + expect(subject).to eql([:firstname, :lastname, :id]) + end + end + + context 'with a partial list and extra attributes before the others' do + let(:options){{ + attributes_ordering: [:firstname, :lastname], + extra_attributes: :before + }} + let(:list){ + [:id, :lastname, :firstname] + } + + it 'returns an ordered list' do + expect(subject).to eql([:id, :firstname, :lastname]) + end + end + + context 'with a partial list and extra attributes ignored' do + let(:options){{ + attributes_ordering: [:firstname, :lastname], + extra_attributes: :ignored + }} + let(:list){ + [:id, :lastname, :firstname] + } + + it 'returns an ordered list' do + expect(subject).to eql([:firstname, :lastname]) + end end end end diff --git a/spec/unit/writer/test_csv.rb b/spec/unit/writer/test_csv.rb index 0dae3ac..d65c268 100644 --- a/spec/unit/writer/test_csv.rb +++ b/spec/unit/writer/test_csv.rb @@ -75,7 +75,7 @@ module Writer end end - context 'when specifying an ordering preference' do + context 'when specifying a header ordering preference' do let(:args){ [ {}, { attributes_ordering: [:name, :id] } ] } it 'works' do @@ -87,6 +87,43 @@ module Writer end end + context 'when specifying a tuple ordering preference' do + let(:args){ [ {}, { tuple_ordering: [[:name, :desc]] } ] } + + it 'works' do + expected = <<~CSV + 2,Yoann;Guyot + 1,Bernard Lambeau + CSV + expect(subject).to eql(expected) + end + end + + context 'when specifying some grouping attributes' do + let(:args){ + [ {}, { grouping_attributes: [:date, :reference] } ] + } + + let(:relation) { + Relation.new [ + {date: "2024-01-18", reference: "AAA", id: "1", name: "Bernard Lambeau"}, + {date: "2024-01-18", reference: "AAA", id: "2", name: "Yoann Guyot"}, + {date: "2024-01-18", reference: "BBB", id: "3", name: "Louis Lambeau"}, + {date: "2024-01-19", reference: "AAA", id: "4", name: "David Parloir"}, + ] + } + + it 'works' do + expected = <<~CSV + 2024-01-18,AAA,1,Bernard Lambeau + ,,2,Yoann Guyot + ,BBB,3,Louis Lambeau + 2024-01-19,AAA,4,David Parloir + CSV + expect(subject).to eql(expected) + end + end + end end end