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.
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.
@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.
Troche offtopicu, jesli mozna.
Masz jakies limity co do dlugosci lini kodu?
Nie denerwuje cie, ze wywolania funkcji etc. sa takie dlugasne?
@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.
Marwemu chodzi (najprawdopodobniej) o takie kawalki:"AggregateRows_sum_adds_row_with_sum_of_all_cells_in_column". Ktos inny potem pracuje z Twoim kodem ?
@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
@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.
@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…
@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ń:)
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 ;-).