1 module dataframes;
2 
3 import std.traits;
4 import std.range;
5 import std.algorithm;
6 import std.array;
7 import std.format;
8 import std.json;
9 import std.conv : text, to;
10 import std.variant;
11 import core.exception;
12 import std.datetime;
13 
14 public
15 {
16     import dataframes.helpers;
17     import dataframes.columns;
18     import dataframes.rows;
19 }
20 
21 /**
22  * DataFrame
23  */
24 class DataFrame(T)
25 {
26     alias fieldNames = FieldNameTuple!(T);
27     alias fieldTypes = FieldTypeTuple!(T);
28 
29     size_t[] index;
30     alias RowType = Row!T;
31 
32     static foreach(idx, type; fieldTypes)
33     {
34         mixin(Column!(type).stringof ~ " " ~ fieldNames[idx] ~ ";");
35     }
36 
37     private static string initializerArgs()
38     {
39         import std.array;
40 
41         string[] args;
42         static foreach(idx, type; fieldTypes)
43         {
44             // Ex: double[] price = []
45             args ~= type.stringof ~ "[] " ~ fieldNames[idx] ~ " = []";
46         }
47         return args.join(", ");
48     }
49 
50     private static string initializerContent()
51     {
52         string content;
53         content ~= "size_t dfSize = 0;";
54         content ~= "bool allColumnSameSize = true;";
55 
56         static foreach(idx, name; fieldNames)
57         {
58             // Ex: if (dfSize == 0) dfSize = FIELD.length
59             content ~= "if (dfSize == 0) dfSize = " ~ name ~ ".length;";
60             // Ex: if (dfSize != FIELD.length && FIELD.length > 0)
61             //         allColumnSameSize = false;
62             content ~= "if (dfSize != " ~ name ~ ".length && " ~ name ~ ".length > 0) allColumnSameSize = false;";
63             // Ex: if (FIELD.length > 0)
64             //         this.FIELD = FIELD;
65             //     else
66             //         this.FIELD.values.length = dfSize;
67             content ~= "if (" ~ name ~ ".length > 0) this." ~ name ~ ".values = " ~ name ~ "; else this." ~ name ~ ".values.length = dfSize;";
68         }
69 
70         content ~= "if (!allColumnSameSize) throw new DataFrameException(\"All arrays must be of the same length\");";
71         content ~= "this.index = iota(length).array;";
72 
73         return content;
74     }
75 
76     mixin("this(" ~ initializerArgs ~ "){" ~ initializerContent ~ "}");
77 
78     size_t opDollar(size_t pos)()
79     {
80         return length;
81     }
82 
83     auto opSlice()(size_t start, size_t end)
84     {
85         return iota(start, end).map!(idx => row(idx)).array;
86     }
87 
88     /**
89      * Access the DataFrame column by label
90      */
91     Variant opIndex(string label)
92     {
93         Variant output;
94     s1: switch (label)
95         {
96             static foreach(name; fieldNames)
97             {
98                 mixin("case \"" ~ name ~ "\": output = this." ~ name ~ "; break s1;");
99             }
100         default:
101             throw new RangeError("invalid label");
102         }
103         return output;
104     }
105 
106     /**
107      * Access the DataFrame column by index
108      */
109     Variant opIndex(size_t idx)
110     {
111         return opIndex(columnNames[idx]);
112     }
113 
114     /**
115      * Get the list of column names
116      */
117     string[] columnNames()
118     {
119         string[] names;
120         foreach(name; fieldNames)
121             names ~= name;
122 
123         return names;
124     }
125 
126     /**
127      * Get the number of columns
128      */
129     size_t ncol()
130     {
131         return fieldNames.length;
132     }
133 
134     /**
135      * Get the number of rows
136      */
137     size_t length()
138     {
139         return  __traits(getMember, this, fieldNames[0]).length;
140     }
141 
142     /**
143      * Get the number of rows. Alias of `length`
144      */
145     size_t nrow()
146     {
147         return length;
148     }
149 
150     /**
151      * Add a row to the dataframe.
152      */
153     void add(T data)
154     {
155         // Expands to this.fieldName ~= data.fieldName;
156         static foreach(name; fieldNames)
157             mixin("this." ~ name ~ " ~= " ~ "data." ~ name ~ ";");
158 
159         this.index ~= length - 1;
160     }
161 
162     RowType row(size_t idx)
163     {
164         return Row!T(this, idx);
165     }
166 
167     RowType[] rows()
168     {
169         RowType[] output;
170         output.length = length;
171         foreach(idx; 0..length)
172             output[idx] = RowType(this, idx);
173         return output;
174     }
175 
176     RowType[] head(size_t count = 10)
177     {
178         if (length < count)
179             count = length;
180 
181         RowType[] output;
182         output.length = count;
183         foreach(idx; 0..count)
184             output[idx] = RowType(this, idx);
185 
186         return output;
187     }
188 
189     RowType[] tail(size_t count = 10)
190     {
191         auto start = length - count;
192         if (length < count)
193         {
194             start = 0;
195             count = length;
196         }
197 
198         RowType[] output;
199         output.length = count;
200         foreach(idx; start..length)
201             output[idx-start] = RowType(this, idx);
202 
203         return output;
204     }
205 
206     private string formatRow(size_t idx)
207     {
208         auto output = appender!string;
209 
210         output ~= format("%10s  ", idx);
211 
212         static foreach(name; fieldNames)
213         {
214             static if(isNumeric!(typeof( __traits(getMember, this, name).values[0])))
215             {
216                 if (is(typeof( __traits(getMember, this, name).values[0]) == double))
217                     output.put(format("%10.2f  ", __traits(getMember, this, name)[idx]));
218                 else
219                     output.put(format("%10s  ", __traits(getMember, this, name)[idx]));
220             }
221             else
222                 output.put(format("%-10s  ", __traits(getMember, this, name)[idx]));
223         }
224 
225         return output.data ~ "\n";
226     }
227 
228     private string formatHeader()
229     {
230         auto output = appender!string;
231 
232         output ~= format("%10s  ", "Index");
233 
234         if (length > 0)
235         {
236             foreach(cname; fieldNames)
237             {
238                 static if(isNumeric!(typeof( __traits(getMember, this, cname).values[0])))
239                     output.put(format("%10s  ", cname));
240                 else
241                     output.put(format("%-10s  ", cname));
242             }
243 
244             output ~= "\n";
245         }
246 
247         return output.data;
248     }
249 
250     override string toString()
251     {
252         auto output = appender!string;
253         // TODO: Print Title and field information
254 
255         output ~= formatHeader;
256 
257         if (length <= 10)
258         {
259             foreach(r; this.rows)
260                 output ~= formatRow(r.index);
261         }
262         else
263         {
264             this.head(5).each!(r => output ~= formatRow(r.index));
265 
266             output.put(format("%10s\n%10s\n", ".", "."));
267 
268             this.tail(5).each!(r => output ~= formatRow(r.index));
269         }
270 
271         output.put(i"\n$(length) rows".text);
272 
273         return output.data;
274     }
275 
276     DataFrame!T toDataFrame()
277     {
278         return toDataFrame!T;
279     }
280 
281     DataFrame!T1 toDataFrame(T1)()
282     {
283         auto output = new DataFrame!T1;
284         foreach(r; this.rows)
285             output.add(r.toDataFrameStruct!T1);
286 
287         return output;
288     }
289 
290     Column!T1 toColumn(T1, string colName)(RowType[] rows)
291     {
292         Column!T1 output;
293         foreach(row; rows)
294             mixin("output ~= row." ~ colName ~ ";");
295 
296         return output;
297     }
298 
299     private void applyLogic(T1)(T1[] data, ref T1 field, string logic)
300     {
301         switch(logic)
302         {
303         case "first":
304             if (data.length > 0)
305                 field = data[0];
306             break;
307         case "last":
308             if (data.length > 0)
309                 field = data[$-1];
310             break;
311         case "max":
312             field = data.maxElement;
313             break;
314         case "min":
315             field = data.minElement;
316             break;
317         case "count":
318             static if (!is(T1 == Date))
319                 field = data.length.to!T1;
320             break;
321         case "sum":
322             static if (isNumeric!(T1))
323                 field = data.sum;
324             break;
325         default:
326             break;
327         }
328     }
329 
330     private void setAggregateLogic(RowType[] rowsdata, ref T row, string fieldName, string logic)
331     {
332     ss:switch(fieldName)
333         {
334             static foreach(idx, name; fieldNames)
335             {
336                 // Ex:
337                 // case "<column-name>":
338                 //     auto col = toColumn!(<type>, <name>)(rowsdata);
339                 //     applyLogic!(Type)(col.values, row.<name>, logic);
340                 //     break;
341                 mixin("case name:
342                            auto col = toColumn!(" ~ fieldTypes[idx].stringof ~ ", \"" ~ name ~ "\")(rowsdata);
343                            applyLogic!(" ~ fieldTypes[idx].stringof ~ ")(col.values, row." ~ name ~ ", logic);
344                            break ss;");
345             }
346         default:
347             break;
348         }
349     }
350 
351     DataFrame!T resample(T1)(T1 grouped, string[string] logic)
352     {
353         auto df = new DataFrame!T;
354         foreach(grp; grouped)
355         {
356             T output;
357             foreach(kv; logic.byKeyValue)
358                 setAggregateLogic(grp.array, output, kv.key, kv.value);
359 
360             df.add(output);
361         }
362 
363         return df;
364     }
365 
366     // Resample using same logic for all the fields
367     DataFrame!T resample(T1)(T1 grouped, string logic)
368     {
369         string[string] logic2;
370         static foreach(name; fieldNames)
371             logic2[name] = logic;
372         return resample(grouped, logic2);
373     }
374 
375     // Apply logic in the same order of the struct fields
376     DataFrame!T resample(T1)(T1 grouped, string[] logic)
377     {
378         string[string] logic2;
379         static foreach(idx, name; fieldNames)
380         {
381             if (logic.length > idx)
382                 logic2[name] = logic[idx];
383         }
384         return resample(grouped, logic2);
385     }
386 
387     JSONValue toJSON()
388     {
389         JSONValue output = JSONValue.emptyArray;
390         foreach(row; this.rows)
391             output.array ~= row.toJSON;
392 
393         return output;
394     }
395 }
396 
397 unittest
398 {
399     struct Item
400     {
401         string name;
402         double price;
403         int quantity;
404         double totalPrice;
405     }
406 
407     auto df = new DataFrame!Item(
408         name: ["A", "B", "C", "D"],
409         price: [149.0, 799.0, 399.0, 299.0],
410         quantity: [3, 1, 2, 1]
411         );
412 
413     assert(df.ncol == 4);
414     assert(df.nrow == 4);
415     assert(df.length == 4);
416 
417     assert(df.columnNames == ["name", "price", "quantity", "totalPrice"]);
418 
419     // Try to add column data of different length
420     // than the other columns.
421     import std.exception;
422     assertThrown!DataFrameException(df.totalPrice = [10, 20]);
423 
424     // Values for this column not set during initialization,
425     // but DataFrame should set the size as same as other columns
426     assert(df.totalPrice.length == 4);
427 
428     auto item = Item("E", 49.0, 10);
429     df.add(item);
430     assert(df.nrow == 5);
431 
432     auto row = Row!Item(df, 0);
433     assert(row.name == "A");
434     assert(row.price == 149.0);
435     assert(row.quantity == 3);
436 
437     auto row2 = Row!Item(df, 3);
438     assert(row2.name == "D");
439     assert(row2.price == 299.0);
440     assert(row2.quantity == 1);
441 
442     assert(df.row(0).name == "A");
443 
444     df.totalPrice = df.price * df.quantity;
445     assert(df.totalPrice == Column!double([447.0, 799.0, 798.0, 299.0, 490.0]));
446 
447     Column!double discounts;
448     foreach(name; df.name)
449     {
450         if (name == "A")
451             discounts ~= 0.05;
452         else if (name == "C")
453             discounts ~= 0.02;
454         else
455             discounts ~= 0;
456     }
457 
458     df.totalPrice = (df.price - discounts) * df.quantity;
459     // TODO: Fix approxEqual to fix the below test
460     //assert(df.totalPrice == Column!double([446.85, 799, 797.96, 299, 490]));
461 
462     import std.algorithm;
463 
464     assert(df.rows
465            .filter!(r => r.name == "A" || r.name == "B")
466            .map!(r => r.quantity)
467            .sum == 4);
468 
469     df.add(Item("A", 149.0, 10));
470 
471     // Sort by name and then by quantity
472     auto result = df.rows
473         .multiSort!("a.name < b.name", "a.quantity > b.quantity", SwapStrategy.unstable);
474 
475     assert(result[0].name == "A" && result[1].name == "A");
476     assert(result[0].quantity == 10.0 && result[1].quantity == 3.0);
477     assert(result[2].name == "B");
478     assert(result[3].name == "C");
479     assert(result[4].name == "D");
480     assert(result[5].name == "E");
481 
482     // Access the Row column by label
483     assert(result[0]["name"].get!string == "A");
484 
485     // Access the Row column by index
486     assert(result[0][0].get!string == "A");
487 
488     struct PriceList
489     {
490         string name;
491         double price;
492     }
493 
494     auto items = df.rows
495         .sort!("a.name < b.name")
496         .uniq!("a.name == b.name")
497         .toDataFrame!PriceList;
498 
499     assert(items.length == 5);
500     assert(items.ncol == 2);
501     assert(items.columnNames == ["name", "price"]);
502 
503     auto dfCopy = df.toDataFrame;
504     assert(dfCopy.ncol == 4);
505     assert(dfCopy.nrow == 6);
506 
507     auto itemsDf = df.toDataFrame!PriceList;
508     assert(itemsDf.ncol == 2);
509     assert(itemsDf.nrow == 6);
510 
511     auto firstTwo = df.head(2);
512     assert(firstTwo.length == 2);
513     assert(firstTwo[0].name == "A");
514     assert(firstTwo[1].name == "B");
515 
516     auto lastTwo = df.tail(2);
517     assert(lastTwo.length == 2);
518     assert(lastTwo[0].name == "E");
519     assert(lastTwo[1].name == "A");
520 
521     // Access the DataFrame column by key
522     auto names1 = df["name"].get!(Column!string);
523     assert(names1[0] == "A");
524     auto names2 = df[0].get!(Column!string);
525     assert(names2[0] == "A");
526 
527     import std.stdio;
528 
529     struct Sample
530     {
531         string name;
532         string name2;
533         double value1;
534         double value2;
535         size_t count;
536     }
537 
538     auto df2 = new DataFrame!Sample(
539         name: ["A", "A", "B", "B", "B", "C"],
540         name2: ["A1", "A2", "B1", "B2", "B3", "C1"],
541         value1: [1, 2, 3, 4, 5, 6],
542         value2: [10, 20, 30, 40, 50, 60],
543         count: [0, 0, 0, 0, 0, 0]
544     );
545 
546     auto grouped = df2.rows.chunkBy!((a, b) => a.name == b.name);
547     auto logic = ["name": "first", "value1": "max", "value2": "sum", "name2": "last", "count": "count"];
548     auto df3 = df2.resample(grouped, logic);
549     assert(df3.nrow == 3);
550     assert(df3.name.values == ["A", "B", "C"]);
551     assert(df3.name2.values == ["A2", "B3", "C1"]);
552     assert(df3.value1.values == [2.0, 5.0, 6.0]);
553     assert(df3.value2.values == [30.0, 120.0, 60.0]);
554     assert(df3.count.values == [2, 3, 1]);
555 
556     auto df4 = df2.resample(df2.rows.chunkBy!((a, b) => a.name == b.name), "sum");
557     assert(df4.nrow == 3);
558     assert(df4.name.values == ["", "", ""]);
559     assert(df4.name2.values == ["", "", ""]);
560     assert(df4.value1.values == [3.0, 12.0, 6.0]);
561     assert(df4.value2.values == [30.0, 120.0, 60.0]);
562     assert(df4.count.values == [0, 0, 0]);
563 
564     auto df5 = df2.resample(df2.rows.chunkBy!((a, b) => a.name == b.name), ["first", "last", "max", "sum"]);
565     assert(df5.nrow == 3);
566     assert(df5.name.values == ["A", "B", "C"]);
567     assert(df5.name2.values == ["A2", "B3", "C1"]);
568     assert(df5.value1.values == [2.0, 5.0, 6.0]);
569     assert(df5.value2.values == [30.0, 120.0, 60.0]);
570     assert(df5.count.values == [0, 0, 0]);
571 
572     auto jsonOutput = `[{"count":0,"name":"A","name2":"A2","value1":2.0,"value2":30.0},
573                         {"count":0,"name":"B","name2":"B3","value1":5.0,"value2":120.0},
574                         {"count":0,"name":"C","name2":"C1","value1":6.0,"value2":60.0}]`;
575     assert(df5.toJSON == parseJSON(jsonOutput));
576 
577     struct Sample2
578     {
579         string name;
580         double value;
581         double avg;
582     }
583 
584     auto dfSample2 = new DataFrame!Sample2(
585         name: ["A", "A", "B", "B", "B", "C"],
586         value: [1, 2, 3, 4, 5, 6]
587     );
588 
589     import std.math : isNaN;
590 
591     dfSample2.avg = dfSample2.value.rolling!(mean, double)(2);
592     assert(dfSample2.avg[0].isNaN);
593     assert(dfSample2.avg.values[1..$] == [1.5, 2.5, 3.5, 4.5, 5.5]);
594 
595     double smaFunc(Row!Sample2[] data)
596     {
597         // Get the mean of the field "value"
598         return data.map!(a => a.value).mean;
599     }
600 
601     dfSample2.avg = dfSample2.rolling!(smaFunc, double)(2);
602     assert(dfSample2.avg[0].isNaN);
603     assert(dfSample2.avg.values[1..$] == [1.5, 2.5, 3.5, 4.5, 5.5]);
604 }