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.

0 0 votes
Article Rating
10 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
PiotrB
13 years ago

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
13 years ago

@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
13 years ago

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

procent
13 years ago

@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_
klm_
13 years ago

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
13 years ago

@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
13 years ago

@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_
klm_
13 years ago

@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
13 years ago

@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
13 years ago

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

Książka

Zobacz również