Browse Source
Implemented a whole new class to represent the data that comes in from CSV and XLSX. See docs for more info. Closes #153dev
9 changed files with 484 additions and 30 deletions
@ -0,0 +1,85 @@ |
|||||||
|
Squib::DataFrame |
||||||
|
================ |
||||||
|
|
||||||
|
As described in :doc:`/data`, the ``Squib::DataFrame`` is what is returned by Squib's data import methods (:doc:`/dsl/csv` and :doc:`/dsl/xlsx`). |
||||||
|
|
||||||
|
It behaves like a ``Hash`` of ``Arrays``, so acessing an individual column can be done via the square brackets, e.g. ``data['title']``. |
||||||
|
|
||||||
|
Here are some other convenience methods in ``Squib::DataFrame`` |
||||||
|
|
||||||
|
columns become methods |
||||||
|
---------------------- |
||||||
|
|
||||||
|
Through magic of Ruby metaprogramming, every column also becomes a method on the data frame. So these two are equivalent: |
||||||
|
|
||||||
|
.. code-block:: irb |
||||||
|
|
||||||
|
irb(main):002:0> data = Squib.csv file: 'basic.csv' |
||||||
|
=> #<Squib::DataFrame:0x00000003764550 @hash={"h1"=>[1, 3], "h2"=>[2, 4]}> |
||||||
|
irb(main):003:0> data.h1 |
||||||
|
=> [1, 3] |
||||||
|
irb(main):004:0> data['h1'] |
||||||
|
=> [1, 3] |
||||||
|
|
||||||
|
#columns |
||||||
|
-------- |
||||||
|
|
||||||
|
Returns an array of the column names in the data frame |
||||||
|
|
||||||
|
#ncolumns |
||||||
|
--------- |
||||||
|
|
||||||
|
Returns the number of columns in the data frame |
||||||
|
|
||||||
|
#col?(name) |
||||||
|
----------- |
||||||
|
|
||||||
|
Returns ``true`` if there is column ``name``. |
||||||
|
|
||||||
|
#row(i) |
||||||
|
------- |
||||||
|
|
||||||
|
Returns a hash of values across all columns in the ``i``th row of the dataframe. Represents a single card. |
||||||
|
|
||||||
|
#nrows |
||||||
|
------ |
||||||
|
|
||||||
|
Returns the number of rows the data frame has, computed by the maximum length of any column array. |
||||||
|
|
||||||
|
#to_json |
||||||
|
-------- |
||||||
|
|
||||||
|
Returns a ``json`` representation of the entire data frame. |
||||||
|
|
||||||
|
#to_pretty_json |
||||||
|
--------------- |
||||||
|
|
||||||
|
Returns a ``json`` representation of the entire data frame, formatted with indentation for human viewing. |
||||||
|
|
||||||
|
#to_pretty_text |
||||||
|
--------------- |
||||||
|
|
||||||
|
Returns a textual representation of the dataframe that emulates what the information looks like on an individual card. Here's an example: |
||||||
|
|
||||||
|
.. code-block:: text |
||||||
|
|
||||||
|
╭------------------------------------╮ |
||||||
|
Name | Mage | |
||||||
|
Cost | 1 | |
||||||
|
Description | You may cast 1 spell per turn | |
||||||
|
Snark | Magic, dude. | |
||||||
|
╰------------------------------------╯ |
||||||
|
╭------------------------------------╮ |
||||||
|
Name | Rogue | |
||||||
|
Cost | 2 | |
||||||
|
Description | You always take the first turn. | |
||||||
|
Snark | I like to be sneaky | |
||||||
|
╰------------------------------------╯ |
||||||
|
╭------------------------------------╮ |
||||||
|
Name | Warrior | |
||||||
|
Cost | 3 | |
||||||
|
Description | |
||||||
|
Snark | I have a long story to tell to tes | |
||||||
|
| t the word-wrapping ability of pre | |
||||||
|
| tty text formatting. | |
||||||
|
╰------------------------------------╯ |
||||||
@ -0,0 +1,108 @@ |
|||||||
|
# encoding: UTF-8 |
||||||
|
|
||||||
|
require 'json' |
||||||
|
require 'forwardable' |
||||||
|
|
||||||
|
module Squib |
||||||
|
class DataFrame |
||||||
|
include Enumerable |
||||||
|
|
||||||
|
def initialize(hash = {}, def_columns = true) |
||||||
|
@hash = hash |
||||||
|
columns.each { |col| def_column(col) } if def_columns |
||||||
|
end |
||||||
|
|
||||||
|
def each(&block) |
||||||
|
@hash.each(&block) |
||||||
|
end |
||||||
|
|
||||||
|
def [](i) |
||||||
|
@hash[i] |
||||||
|
end |
||||||
|
|
||||||
|
def []=(col, v) |
||||||
|
@hash[col] = v |
||||||
|
def_column(col) |
||||||
|
return v |
||||||
|
end |
||||||
|
|
||||||
|
def columns |
||||||
|
@hash.keys |
||||||
|
end |
||||||
|
|
||||||
|
def ncolumns |
||||||
|
@hash.keys.size |
||||||
|
end |
||||||
|
|
||||||
|
def col?(col) |
||||||
|
@hash.key? col |
||||||
|
end |
||||||
|
|
||||||
|
def row(i) |
||||||
|
@hash.inject(Hash.new) { |ret, (name, arr)| ret[name] = arr[i]; ret } |
||||||
|
end |
||||||
|
|
||||||
|
def nrows |
||||||
|
@hash.inject(0) { |max, (_n, col)| col.size > max ? col.size : max } |
||||||
|
end |
||||||
|
|
||||||
|
def to_json |
||||||
|
@hash.to_json |
||||||
|
end |
||||||
|
|
||||||
|
def to_pretty_json |
||||||
|
JSON.pretty_generate(@hash) |
||||||
|
end |
||||||
|
|
||||||
|
def to_h |
||||||
|
@hash |
||||||
|
end |
||||||
|
|
||||||
|
def to_pretty_text |
||||||
|
max_col = columns.inject(0) { |max, c | c.length > max ? c.length : max } |
||||||
|
top = " ╭#{'-' * 36}╮\n" |
||||||
|
bottom = " ╰#{'-' * 36}╯\n" |
||||||
|
str = '' |
||||||
|
0.upto(nrows - 1) do | i | |
||||||
|
str += (' ' * max_col) + top |
||||||
|
row(i).each do |col, data| |
||||||
|
str += "#{col.rjust(max_col)} #{wrap_n_pad(data, max_col)}" |
||||||
|
end |
||||||
|
str += (' ' * max_col) + bottom |
||||||
|
end |
||||||
|
return str |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def snake_case(str) |
||||||
|
str.to_s. |
||||||
|
strip. |
||||||
|
gsub(/\s+/,'_'). |
||||||
|
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). |
||||||
|
gsub(/([a-z]+)([A-Z])/,'\1_\2'). |
||||||
|
downcase. |
||||||
|
to_sym |
||||||
|
end |
||||||
|
|
||||||
|
def wrap_n_pad(str, max_col) |
||||||
|
str.to_s. |
||||||
|
concat(' '). # handle nil & empty strings |
||||||
|
scan(/.{1,34}/). |
||||||
|
map { |s| (' ' * max_col) + " | " + s.ljust(34) }. |
||||||
|
join(" |\n"). |
||||||
|
lstrip. # initially no whitespace next to key |
||||||
|
concat(" |\n") |
||||||
|
end |
||||||
|
|
||||||
|
def def_column(col) |
||||||
|
raise "Column #{col} - does not exist" unless @hash.key? col |
||||||
|
method_name = snake_case(col) |
||||||
|
return if self.class.method_defined?(method_name) #warn people? or skip? |
||||||
|
define_singleton_method method_name do |
||||||
|
@hash[col] |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
end |
||||||
|
end |
||||||
@ -0,0 +1,251 @@ |
|||||||
|
# encoding: UTF-8 |
||||||
|
|
||||||
|
require 'spec_helper' |
||||||
|
require 'squib/import/data_frame' |
||||||
|
|
||||||
|
describe Squib::DataFrame do |
||||||
|
let(:basic) do |
||||||
|
{ |
||||||
|
'Name' => ['Mage', 'Rogue', 'Warrior'], |
||||||
|
'Cost' => [1, 2, 3], |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
let(:uneven) do |
||||||
|
{ |
||||||
|
'Name' => ['Mage', 'Rogue', 'Warrior'], |
||||||
|
'Cost' => [1, 2], |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
let(:whitespace) do |
||||||
|
{ |
||||||
|
'Name \n\r\t' => [' Mage \t\r\n'], |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
let(:defined) do |
||||||
|
{ |
||||||
|
'nrows' => [1,2,3], |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
context 'is Enumerable and like a hash, so it' do |
||||||
|
it 'responds to each' do |
||||||
|
expect(subject).to respond_to(:each) |
||||||
|
end |
||||||
|
|
||||||
|
it 'responds to any?' do |
||||||
|
expect(subject.any?).to be false |
||||||
|
end |
||||||
|
|
||||||
|
it 'responds to []' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
expect(data['Cost']).to eq([1, 2, 3]) |
||||||
|
end |
||||||
|
|
||||||
|
it 'responds to []=' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
data[:a] = 2 |
||||||
|
expect(data[:a]).to eq(2) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context :columns do |
||||||
|
it 'provides a list of columns' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
expect(data.columns).to eq %w(Name Cost) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context :ncolumns do |
||||||
|
it 'provides column count' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
expect(data.ncolumns).to eq 2 |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context :row do |
||||||
|
it 'returns a hash of each row' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
expect(data.row(0)).to eq ({'Name' => 'Mage', 'Cost' => 1}) |
||||||
|
expect(data.row(1)).to eq ({'Name' => 'Rogue', 'Cost' => 2}) |
||||||
|
end |
||||||
|
|
||||||
|
it 'returns nil for uneven data' do |
||||||
|
data = Squib::DataFrame.new uneven |
||||||
|
expect(data.row(2)).to eq ({'Name' => 'Warrior', 'Cost' => nil}) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context :nrows do |
||||||
|
it 'returns a row count on even data' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
expect(data.nrows).to eq 3 |
||||||
|
end |
||||||
|
|
||||||
|
it 'returns largest row count on uneven data' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
expect(data.nrows).to eq 3 |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context :json do |
||||||
|
it 'returns quoty json' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
expect(data.to_json).to eq "{\"Name\":[\"Mage\",\"Rogue\",\"Warrior\"],\"Cost\":[1,2,3]}" |
||||||
|
end |
||||||
|
|
||||||
|
it 'returns pretty json' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
str = <<-EOS |
||||||
|
{ |
||||||
|
"Name": [ |
||||||
|
"Mage", |
||||||
|
"Rogue", |
||||||
|
"Warrior" |
||||||
|
], |
||||||
|
"Cost": [ |
||||||
|
1, |
||||||
|
2, |
||||||
|
3 |
||||||
|
] |
||||||
|
} |
||||||
|
EOS |
||||||
|
expect(data.to_pretty_json).to eq str.chomp |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context :def_column do |
||||||
|
it 'creates name for Name column' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
expect(data).to respond_to(:name) |
||||||
|
expect(data.name).to eq %w(Mage Rogue Warrior) |
||||||
|
expect(data.cost).to eq [1, 2, 3] |
||||||
|
end |
||||||
|
|
||||||
|
it 'does not redefine methods' do |
||||||
|
data = Squib::DataFrame.new defined |
||||||
|
expect(data).to respond_to(:nrows) |
||||||
|
expect(data.nrows).to eq 3 |
||||||
|
end |
||||||
|
|
||||||
|
it 'defines a new column from empty data frame' do |
||||||
|
data = Squib::DataFrame.new |
||||||
|
expect(data).not_to respond_to(:snark) |
||||||
|
data['snark'] = [1,2,3] |
||||||
|
expect(data.snark).to eq [1,2,3] |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context :snake_case do |
||||||
|
subject { Squib::DataFrame.new } |
||||||
|
it 'strips leading & trailing whitespace' do |
||||||
|
expect(subject.send(:snake_case, ' A ')).to eq :a |
||||||
|
end |
||||||
|
|
||||||
|
it 'converts space to _' do |
||||||
|
expect(subject.send(:snake_case, 'A b')).to eq :a_b |
||||||
|
end |
||||||
|
|
||||||
|
it 'handles multiwhitespace' do |
||||||
|
expect(subject.send(:snake_case, 'A b')).to eq :a_b |
||||||
|
end |
||||||
|
|
||||||
|
it 'handles camelcase' do |
||||||
|
expect(subject.send(:snake_case, 'fooBar')).to eq :foo_bar |
||||||
|
end |
||||||
|
|
||||||
|
it 'handles camelcase with multiple capitals' do |
||||||
|
expect(subject.send(:snake_case, 'FOOBar')).to eq :foo_bar |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context :col? do |
||||||
|
it 'returns true if a column exists' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
expect(data.col? 'Name').to be true |
||||||
|
end |
||||||
|
|
||||||
|
it 'returns false if a column does not exist' do |
||||||
|
data = Squib::DataFrame.new basic |
||||||
|
expect(data.col? 'ROUS').to be false |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context :wrap_n_pad do |
||||||
|
subject { Squib::DataFrame.new basic } |
||||||
|
|
||||||
|
it 'returns a handles a single line' do |
||||||
|
expect(subject.send(:wrap_n_pad, 'a', 3)). |
||||||
|
to eq "| a |\n" |
||||||
|
end |
||||||
|
|
||||||
|
it 'wraps multiple a lines at 34 characters' do |
||||||
|
expect(subject.send(:wrap_n_pad, 'a' * 40, 3)). |
||||||
|
to eq <<-EOS |
||||||
|
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | |
||||||
|
| aaaaaa | |
||||||
|
EOS |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
context :to_pretty_text do |
||||||
|
|
||||||
|
let(:verbose) do |
||||||
|
{ |
||||||
|
'Name' => ['Mage', 'Rogue', 'Warrior'], |
||||||
|
'Cost' => [1, 2, 3], |
||||||
|
'Description' => [ |
||||||
|
'Magic, dude.', |
||||||
|
'I like to be sneaky', |
||||||
|
'I have a long story to tell to test the word-wrapping ability of pretty text formatting.' |
||||||
|
], |
||||||
|
'Snark' => [nil, '', ' '] |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
it 'returns fun ascii art of the card' do |
||||||
|
data = Squib::DataFrame.new verbose |
||||||
|
expect(data.to_pretty_text).to eq <<-EOS |
||||||
|
╭------------------------------------╮ |
||||||
|
Name | Mage | |
||||||
|
Cost | 1 | |
||||||
|
Description | Magic, dude. | |
||||||
|
Snark | | |
||||||
|
╰------------------------------------╯ |
||||||
|
╭------------------------------------╮ |
||||||
|
Name | Rogue | |
||||||
|
Cost | 2 | |
||||||
|
Description | I like to be sneaky | |
||||||
|
Snark | | |
||||||
|
╰------------------------------------╯ |
||||||
|
╭------------------------------------╮ |
||||||
|
Name | Warrior | |
||||||
|
Cost | 3 | |
||||||
|
Description | I have a long story to tell to tes | |
||||||
|
| t the word-wrapping ability of pre | |
||||||
|
| tty text formatting. | |
||||||
|
Snark | | |
||||||
|
╰------------------------------------╯ |
||||||
|
EOS |
||||||
|
end |
||||||
|
|
||||||
|
let(:utf8_fun) do |
||||||
|
{ |
||||||
|
'Fun with UTF8!' => ['👊'], |
||||||
|
} |
||||||
|
end |
||||||
|
|
||||||
|
it 'is admittedly janky with multibyte chars' do |
||||||
|
# I hate how Ruby handles multibyte chars. Blech! |
||||||
|
data = Squib::DataFrame.new utf8_fun |
||||||
|
expect(data.to_pretty_text).to eq <<-EOS |
||||||
|
╭------------------------------------╮ |
||||||
|
Fun with UTF8! | 👊 | |
||||||
|
╰------------------------------------╯ |
||||||
|
EOS |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
end |
||||||
Loading…
Reference in new issue