fbpx
devstyle.pl - Blog dla każdego programisty
devstyle.pl - Blog dla każdego programisty
7 minut

Dodawanie agregacji do datatable: DataTable.AsEnumerableWithAggregateRows()


17.02.2011

Kilka tygodni temu miałem za zadanie wygenerować na stronie tabelkę dla pewnych danych, z zastrzeżeniem, że ostatnie wiersze powinny zawierać sumę i średnią wszystkich komórek powyżej. Nic nadzwyczajnego.

Jednak pojawił się jeden problem: źródłowa tabela takich danych nie posiadała. Oczywiste było, że takie wymaganie pojawi się zaraz w kolejnych miejscach. Postanowiłem więc poświęcić kilka minut na zamknięcie takiej funkcjonalności w osobnej metodzie. Dość naturalnym wydało mi się stworzenie odpowiednika dla DataTable.AsEnumerable(), z tym że z "doklejonymi" interesującymi mnie informacjami. Czyli: AsEnumerableWithAggreateRows().

Poniższe testy, które napisałem najpierw, wyrażają moje oczekiwania co do wspomnianej metody:

  1:  [Test]
  2:  public void AggregateRows_does_nothing_with_empty_table()
  3:  {
  4:      var table = new DataTable();
  5:      table.Columns.Add("name", typeof(string));
  6:      table.Columns.Add("val1", typeof(int));
  7:      table.Columns.Add("descr", typeof(string));
  8:      table.Columns.Add("val2", typeof(decimal));
  9:  
 10:      var rows = table.AsEnumerableWithAggregateRows(AggregateRowOption.Sum).ToList();
 11:  
 12:      Assert.AreEqual(0, rows.Count);
 13:  }
 14:  
 15:  [Test]
 16:  public void AggregateRows_does_not_modify_source_table()
 17:  {
 18:      var table = new DataTable();
 19:      table.Columns.Add("name", typeof(string));
 20:      table.Columns.Add("val1", typeof(int));
 21:      table.Columns.Add("descr", typeof(string));
 22:      table.Columns.Add("val2", typeof(decimal));
 23:  
 24:      table.Rows.Add("row 1", 2, "abc", 3.1);
 25:      table.Rows.Add("row 2", 4, "abc", 8);
 26:  
 27:      table.AsEnumerableWithAggregateRows(AggregateRowOption.Sum).ToList();
 28:  
 29:      Assert.AreEqual(2, table.Rows.Count);
 30:  }
 31:  
 32:  [Test]
 33:  public void AggregateRows_sum_adds_row_with_sum_of_all_cells_in_column()
 34:  {
 35:      var table = new DataTable();
 36:      table.Columns.Add("name", typeof (string));
 37:      table.Columns.Add("val1", typeof (int));
 38:      table.Columns.Add("descr", typeof(string));
 39:      table.Columns.Add("val2", typeof(decimal));
 40:  
 41:      table.Rows.Add("row 1", 2, "abc", 3.1);
 42:      table.Rows.Add("row 2", 4, "abc", 8);
 43:  
 44:      var rows = table.AsEnumerableWithAggregateRows(AggregateRowOption.Sum).ToList();
 45:  
 46:      Assert.AreEqual(3, rows.Count);
 47:  
 48:      var sumRow = rows.Last();
 49:  
 50:      Assert.AreEqual(DBNull.Value, sumRow[0]);
 51:      Assert.AreEqual(6m, sumRow[1]);
 52:      Assert.AreEqual(DBNull.Value, sumRow[2]);
 53:      Assert.AreEqual(11.1m, sumRow[3]);
 54:  }
 55:  
 56:  [Test]
 57:  public void AggregateRows_average_adds_row_with_avg_of_all_cells_in_column()
 58:  {
 59:      var table = new DataTable();
 60:      table.Columns.Add("name", typeof (string));
 61:      table.Columns.Add("val1", typeof (int));
 62:      table.Columns.Add("descr", typeof(string));
 63:      table.Columns.Add("val2", typeof (decimal));
 64:  
 65:      table.Rows.Add("row 1", 2, "abc", 6.1);
 66:      table.Rows.Add("row 2", 4, "abc", 4.3);
 67:  
 68:      var rows = table.AsEnumerableWithAggregateRows(AggregateRowOption.Average).ToList();
 69:  
 70:      Assert.AreEqual(3, rows.Count);
 71:  
 72:      var avgRow = rows.Last();
 73:  
 74:      Assert.AreEqual(DBNull.Value, avgRow[0]);
 75:      Assert.AreEqual(3m, avgRow[1]);
 76:      Assert.AreEqual(DBNull.Value, avgRow[2]);
 77:      Assert.AreEqual(5.2m, avgRow[3]);
 78:  }
 79:  
 80:  [Test]
 81:  public void AggregateRows_average_produces_decimal_value_in_integer_column()
 82:  {
 83:      var table = new DataTable();
 84:      table.Columns.Add("val1", typeof (int));
 85:  
 86:      table.Rows.Add(2);
 87:      table.Rows.Add(3);
 88:  
 89:      var rows = table.AsEnumerableWithAggregateRows(AggregateRowOption.Average).ToList();
 90:  
 91:      var avgRow = rows.Last();
 92:      Assert.AreEqual(2.5m, avgRow[0]);
 93:  }
 94:  
 95:  [Test]
 96:  public void AggregateRows_composes_sum_with_average()
 97:  {
 98:      var table = new DataTable();
 99:      table.Columns.Add("val", typeof (int));
100:  
101:      table.Rows.Add(1);
102:      table.Rows.Add(5);
103:      table.Rows.Add(3);
104:  
105:      var rows = table.AsEnumerableWithAggregateRows(AggregateRowOption.Sum, AggregateRowOption.Average).ToList();
106:  
107:      Assert.AreEqual(5, rows.Count);
108:  
109:      Assert.AreEqual(9m, rows[3][0], "sum incorrect");
110:      Assert.AreEqual(3m, rows[4][0], "avg incorrect");
111:  }
112:  
113:  [Test]
114:  public void AggregateRows_composes_average_with_sum()
115:  {
116:      var table = new DataTable();
117:      table.Columns.Add("val", typeof(int));
118:  
119:      table.Rows.Add(1);
120:      table.Rows.Add(5);
121:      table.Rows.Add(3);
122:  
123:      var rows = table.AsEnumerableWithAggregateRows(AggregateRowOption.Average, AggregateRowOption.Sum).ToList();
124:  
125:      Assert.AreEqual(5, rows.Count);
126:  
127:      Assert.AreEqual(3m, rows[3][0], "avg incorrect");
128:      Assert.AreEqual(9m, rows[4][0], "sum incorrect");
129:  }

A oto i sam mechanizm:

  1:  public enum AggregateRowOption
  2:  {
  3:      Sum,
  4:      Average
  5:  }
  6:  
  7:  public static class DataTableExtensions
  8:  {
  9:      public static IEnumerable<DataRow> AsEnumerableWithAggregateRows(this DataTable @this, params AggregateRowOption[] aggregates)
 10:      {
 11:          foreach (var dataRow in @this.AsEnumerable())
 12:          {
 13:              yield return dataRow;
 14:          }
 15:  
 16:          // no data or no aggregate operations passed -> do nothing
 17:          if (@this.Rows.Count == 0 || aggregates.Length == 0)
 18:          {
 19:              yield break;
 20:          }
 21:  
 22:          // has data -> calculate aggregates
 23:  
 24:          // create table that will contain aggregate rows
 25:          DataTable aggregateTable = new DataTable();
 26:          foreach (DataColumn dataColumn in @this.Columns)
 27:          {
 28:              // check if source column is numeric;
 29:              bool numericColumn = dataColumn.DataType.IsNumeric();
 30:              // if yes, new column will be decimal (to hold precise aggregated data)
 31:              // otherwise, copy the type
 32:              Type destinationType = numericColumn ? typeof(decimal) : dataColumn.DataType;
 33:  
 34:              aggregateTable.Columns.Add(dataColumn.ColumnName, destinationType);
 35:          }
 36:  
 37:          var aggregateCreator = new AggregateRowCreator();
 38:  
 39:          foreach (var aggreateOperation in aggregates)
 40:          {
 41:              aggregateTable.Rows.Add(aggregateCreator.CreateAggregatedItems(@this, aggreateOperation));
 42:          }
 43:  
 44:          foreach (var aggregateRow in aggregateTable.AsEnumerable())
 45:          {
 46:              yield return aggregateRow;
 47:          }
 48:      }
 49:  
 50:      private class AggregateRowCreator
 51:      {
 52:          public object[] CreateAggregatedItems(DataTable source, AggregateRowOption operation)
 53:          {
 54:              IAggregator aggregator = AggregatorFor(operation);
 55:  
 56:              List<object> aggregates = new List<object>();
 57:  
 58:              foreach (var column in source.Columns.Cast<DataColumn>())
 59:              {
 60:                  if (column.DataType.IsNumeric())
 61:                  {
 62:                      aggregates.Add(aggregator.Calculate(column));
 63:                  }
 64:                  else
 65:                  {
 66:                      aggregates.Add(DBNull.Value);
 67:                  }
 68:              }
 69:  
 70:              return aggregates.ToArray();
 71:          }
 72:  
 73:          private static IAggregator AggregatorFor(AggregateRowOption operation)
 74:          {
 75:              switch (operation)
 76:              {
 77:                  case AggregateRowOption.Sum:
 78:                      return new SumAggregator();
 79:                  case AggregateRowOption.Average:
 80:                      return new AvgAggregator();
 81:                  default:
 82:                      throw new ArgumentOutOfRangeException();
 83:              }
 84:          }
 85:      }
 86:  
 87:      private interface IAggregator
 88:      {
 89:          object Calculate(DataColumn column);
 90:      }
 91:  
 92:      private class SumAggregator : IAggregator
 93:      {
 94:          public object Calculate(DataColumn column)
 95:          {
 96:              return column.Table.Compute("sum({0})".Fill(column.ColumnName), string.Empty);
 97:          }
 98:      }
 99:  
100:      private class AvgAggregator : IAggregator
101:      {
102:          public object Calculate(DataColumn column)
103:          {
104:              decimal average = column.Table.AsEnumerable().Average(x => decimal.Parse(x[column.ColumnName].ToString()));
105:  
106:              return average;
107:          }
108:      }
109:  }

Śmiga aż miło.

Po raz kolejny słówko yield potrafi uprościć sprawę. Najpierw zwracam wszystkie źródłowe wiersze, a potem do wyników doklejam jeszcze odpowiednie agregacje pochodzące z nowej tabeli.

Zauważcie, że kolumny zwracające wyliczone agregacje zawsze są typu decimal, nawet dla intów. Logiczne – nie wyliczę średniej inaczej. Powoduje to jednak, że nie potraktuję w ten sposób silnie typowanego dataseta… ale też specjalnie z nich jakoś nie korzystam.

Notify of
PiotrB

Takie pytanie do testów: nie robisz refactoringu? Do mnie testy lepiej mówią, jeżeli są w nich tylko niezbędne linijki (w samej metodzie testującej).
Jeżeli chodzi o typowane datasety, jeżeli komuś zależy na nich na wejściu, to zrobi sobie zmiane typu pól i konwersje (Int->Dec), przecież na wyjściu dostaje IEnumerable<DataRow>. Silnie typowane ds są fajne… do pierwszej zmiany specyfikacji.

procent

@PiotrB:
Testy – to zależy od sytuacji. W tym przypadku nie każdy test ma takiego samego dataseta i uznałem, że może być tak jak jest. Często jednak – tak jak sugerujesz – wyrzucam takie "przygotowania" na zewnątrz. O tym zamierzam napisać w niedalekiej przyszłości demonstrując moje zabawy z MSpec, który taką separację wymusza.

marwy
marwy

Troche offtopicu, jesli mozna.
Masz jakies limity co do dlugosci lini kodu?
Nie denerwuje cie, ze wywolania funkcji etc. sa takie dlugasne?

procent

@marwy:
O które linie chodzi? Generalnie dłuższe instrukcje raczej rozbijam na krótsze linie (np budując zapytanie LINQ). Przy jednym projekcie mam ustawiony limit linii na 80 znaków, ale różnie z tym bywa. Przez 80% czasu pracuję na dwóch plikach jednocześnie z pionowym podziałem, czyli na 24" prawie zawsze jest:
jeden plik | drugi plik | solution explorer
I tak mam ustawione zawijanie wierszy w VS, więc jakoś specjalnie uwagi na to nie zwracam.

klm_

Marwemu chodzi (najprawdopodobniej) o takie kawalki:"AggregateRows_sum_adds_row_with_sum_of_all_cells_in_column". Ktos inny potem pracuje z Twoim kodem ?

Gutek

@klm_

ale to jest nazwa metody do testowania. Ja pisze tak by bylo ono zrozumiale, widac podobnie jak @Procent – tutaj nie mam ograniczenia co do dlugosci, ale za dlugie to tez nie moze byc. i latwiej mi sie czyta cos jak jest _ niz AggreagateRowsSumAddsRowWith… ale to chyba kwestia osobista

procent

@klm_:
Tą metodę wywołuje test runner, ja nigdy. Ona ma dokładnie określić co będzie testowane. Czasami piszę jeszcze dłuższe, mój rekord (akurat w MSpec) to (po zawinięciu) dwie linijki nazwy.
Dodatkowo mądre test runnery (jak chociażby ten z R#) usuwa podkreślenia z nazw testów, przez co rezultaty testów dają bardzo czytelny raport.

klm_

@Gutek
Mnie po prostu metody z "_" kojarza sie bardziej z kodem pythona/ruby. W srodowisku .Net raczej CamelCase. Jak ktos inny potem czyta kod napisany w tej samej technologii, ale inne notacji to na poczatku moze miec pewne problemy. Wiadomo, to zalezy od kultury danego zespolu programistycznego itp. ale jednak…

procent

@klm_:
CamelCase przestaje być czytelne przy > 3 słowach. A co do tego:

"Jak ktos inny potem czyta kod napisany w tej samej technologii, ale inne notacji to na poczatku moze miec pewne problemy"

to bez przesady, jak umie czytać to poradzi sobie z parsowaniem podkreśleń:)

marwy
marwy

Jednak nie bede musial tlumaczyc co mialem na mysli :-).
Ja mam maks. 80 znakow w linii i nawet piszac w Pythonie mam z tym – sporadycznie – problemy. A wiadomo, ze C#/Java sa bardziej rozwlekle niz Python, wiec bylem ciekaw jak sobie z tym radzisz. Wychodzi na to ze it’s not you, it’s me ;-).

Kurs Gita

Zaawansowany frontend

Szkolenie z Testów

Szkolenie z baz danych

Facebook

Książka

Zobacz również