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:
17: if (@this.Rows.Count == 0 || aggregates.Length == 0)
18: {
19: yield break;
20: }
21:
22:
23:
24:
25: DataTable aggregateTable = new DataTable();
26: foreach (DataColumn dataColumn in @this.Columns)
27: {
28:
29: bool numericColumn = dataColumn.DataType.IsNumeric();
30:
31:
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.