namo 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG +89 -0
- data/README.md +145 -0
- data/Rakefile +1 -1
- data/lib/Namo/Collection.rb +52 -0
- data/lib/Namo/Row.rb +30 -6
- data/lib/Namo/VERSION.rb +1 -1
- data/lib/namo.rb +15 -1
- data/test/Namo/Collection_test.rb +250 -0
- data/test/Namo/Row_test.rb +105 -0
- data/test/namo_test.rb +123 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ed38a77d14075cbe3c28ff53097d539c2fefe4a91ff515e165e02c280311fd3
|
|
4
|
+
data.tar.gz: ba009835a59174f2e60327f3583753d2e6c2b0e81b06f23de4a15dd792981526
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7328696a6b4032561fa1b68c0eb4d3e7d3582ee4d5fc24926779112413867f1713100d9c21a3aa2f18c299e50802c866b5c95e874e569f4ef41f40e7b3924f58
|
|
7
|
+
data.tar.gz: 8028776be37f1f183ca2cefc3c9de7229d6918cf37d05b5f925675dd2ffd7d05603cc21ba53665f74094991b0c8d08f22479de2f50ee35e36ba772777bdbfb6e
|
data/CHANGELOG
CHANGED
|
@@ -1,6 +1,95 @@
|
|
|
1
1
|
CHANGELOG
|
|
2
2
|
_________
|
|
3
3
|
|
|
4
|
+
20260613
|
|
5
|
+
0.18.0: + Namo::Collection — hierarchical aggregate of named Namos with summary/detail views.
|
|
6
|
+
|
|
7
|
+
1. + lib/Namo/Collection.rb: Namo::Collection < Namo, the first family member beyond Namo
|
|
8
|
+
itself. Holds @members (an Array of named Namos, attr_reader :members) whose substance is
|
|
9
|
+
the collection; the inherited @data is a derived view rebuilt from the members. << accepts a
|
|
10
|
+
member or an array (via flatten), replaces by name (last-write-wins, unless member.name.nil?
|
|
11
|
+
so nil-named members always append), rebuilds @data = detail.data, and returns self. find(name)
|
|
12
|
+
returns the named member or nil (find(nil) is nil, and an unnamed member never matches),
|
|
13
|
+
shadowing Enumerable#find on Collections. summary(dimension, by: :member, reducer: :sum)
|
|
14
|
+
reduces each member to a {by => member.name, dimension => reduced} row; detail(by: :member)
|
|
15
|
+
unions the members' rows under the inject-iff-absent conditional
|
|
16
|
+
(row.key?(by) ? row : row.merge(by => member.name)); both return a fresh Namo (non-mutating).
|
|
17
|
+
as_summary(...) and as_detail(by = :member) set @data to the chosen view and return self
|
|
18
|
+
(mutating); the view persists until the next << re-materialises detail. Private initialize
|
|
19
|
+
sets @members = [] before super.
|
|
20
|
+
2. ~ lib/namo.rb: + require_relative './Namo/Collection'.
|
|
21
|
+
3. + test/Namo/Collection_test.rb: empty members; lazy detail materialisation and selection;
|
|
22
|
+
pure-live reflection of <<; <<' add/replace-by-name/array/unnamed/returns-self; find by name,
|
|
23
|
+
absent, nil, and unnamed; summary's labelled rows, custom by:, :mean reducer (local Array#mean),
|
|
24
|
+
and non-mutation; detail's plain-Namo return, inject-iff-absent both ways, and non-mutation;
|
|
25
|
+
live recomputation after <<; as_summary/as_detail mutate and return self and surface the
|
|
26
|
+
summary's columns in dimensions; as_* view persistence until the next <<; assembly round-trip
|
|
27
|
+
(as_detail(:assembly) injects and is retained, removed only by contraction); subclass default
|
|
28
|
+
by: via a Car < Namo::Collection overriding summary/detail.
|
|
29
|
+
4. ~ README.md: + Collections section after Named Namos — the GT-budget example (Car/SubAssembly,
|
|
30
|
+
<<, summary, detail), lazy detail materialisation, the four view methods and the
|
|
31
|
+
non-mutating/mutating split, the inject-iff-absent rule, << replace-by-name with use-site
|
|
32
|
+
handling of unnamed members, the rebuild-on-<< view lifetime, and forward-notes to group_by
|
|
33
|
+
(0.19.0) and freeze-gated memoisation (2.x).
|
|
34
|
+
5. ~ ROADMAP.md: Promote 0.18.0 to shipped, recording the resolved materialisation model
|
|
35
|
+
(rebuild-on-<<, as_* persists until the next <<, diverging from the drafted pure-live-per-access
|
|
36
|
+
/ transient wording) and the find/Enumerable#find shadowing; Current state -> 0.18.0; Summary
|
|
37
|
+
folds in Namo::Collection; next phase -> group_by (0.19.0). The upcoming 0.18.0 design block
|
|
38
|
+
folded into the shipped entry.
|
|
39
|
+
6. ~ COMPARISON.md: Aggregation entry repointed — Namo::Collection shipped (0.18.0), group_by
|
|
40
|
+
constructor still planned (0.19.0); persistent-named-collection retained as Namo's novelty
|
|
41
|
+
versus the other tools' transient group-by intermediates.
|
|
42
|
+
7. ~ Namo::VERSION: /0.17.0/0.18.0/
|
|
43
|
+
|
|
44
|
+
20260613
|
|
45
|
+
0.17.0: + parameterised formulae — formulae with required parameters beyond (row, namo) receive arguments at access time through Row#[].
|
|
46
|
+
|
|
47
|
+
1. ~ lib/Namo/Row.rb: Row#[] gains a trailing splat and forwards call-site arguments. Dispatch
|
|
48
|
+
generalises from exact arity 2 to required-parameter count via the new private
|
|
49
|
+
collection_scoped? and required_parameter_count — one required parameter or none stays
|
|
50
|
+
row-scoped, two or more call formula.call(self, @namo, *arguments). Settles the 0.15.0
|
|
51
|
+
negative-arity deferral: |row, *rest| and ->(row, namo = nil){} stay row-scoped;
|
|
52
|
+
|row, namo, *fields| is collection-scoped with optional arguments.
|
|
53
|
+
2. ~ lib/Namo/Row.rb: + argument-count enforcement — the new private expected_argument_counts
|
|
54
|
+
and raise_unless_expected_arguments raise ArgumentError ("wrong number of arguments for
|
|
55
|
+
:sma (given 0, expected 2)") on the wrong count for any dimension: too few or too many for
|
|
56
|
+
a fixed-arity formula, fewer than required for a splatted one, any at all for a data
|
|
57
|
+
dimension, a row-scoped formula, or a two-arity formula. Checked before the Namo-context
|
|
58
|
+
guard, whose message generalises from "two-arity formula" to "collection-scoped formula".
|
|
59
|
+
3. ~ lib/namo.rb: the no-arg values (and so coordinates and to_h) materialise via the new
|
|
60
|
+
private materialisable_dimensions, omitting formulae that require arguments (private
|
|
61
|
+
requires_arguments? and required_parameter_count); explicit asks — values(:dim),
|
|
62
|
+
coordinates(:dim), naming the dimension in a projection, selecting on it — raise through
|
|
63
|
+
Row#[]. dimensions and derived_dimensions still list the name; the empty-Namo case returns
|
|
64
|
+
[] without invoking the formula.
|
|
65
|
+
4. ~ test/Namo/Row_test.rb: + "#[] parameterised formulae" describe — arity 3 and 4 receive
|
|
66
|
+
(row, namo, args...), trailing-splat forwarding (arity -3 and -4), one-required-parameter
|
|
67
|
+
procs stay row-scoped, a row-scoped formula calling a parameterised one with arguments,
|
|
68
|
+
missing-Namo-context raise; + "#[] argument-count enforcement" describe — the message
|
|
69
|
+
matrix for too few, too many, splat minimum, and arguments handed to data dimensions,
|
|
70
|
+
row-scoped, two-arity, and missing dimensions.
|
|
71
|
+
5. ~ test/namo_test.rb: + "#[]= parameterised formulae" describe — resolution with arguments
|
|
72
|
+
through yielded Rows, one definition serving different fields and periods, Enumerable
|
|
73
|
+
predicates, a one-arity formula referencing a parameterised one, dimension listing, bulk
|
|
74
|
+
views omitting, explicit values/coordinates/projection/selection raises, carry through
|
|
75
|
+
contraction, selection (windowing over the filtered rows), and set operators, the
|
|
76
|
+
one-arity wrapper materialisation idiom, namo-plus-splat formulae materialising in the
|
|
77
|
+
bulk views, and the empty-Namo case.
|
|
78
|
+
6. ~ README.md: + Parameterised formulae subsection under Formulae — access-time arguments,
|
|
79
|
+
the required-parameter scope rule, argument-count enforcement, materialisation behaviour,
|
|
80
|
+
and the wrapper idiom.
|
|
81
|
+
7. ~ ROADMAP.md: Promote 0.17.0 to shipped with the dispatch-rule, enforcement, and
|
|
82
|
+
materialisation rationale; Current state -> 0.17.0; Summary folds in parameterised
|
|
83
|
+
formulae; next phase -> Namo::Collection (0.18.0). Date bumped.
|
|
84
|
+
8. ~ COMPARISON.md: Parameterised formulae -> shipped (0.17.0), the entry noting access-time
|
|
85
|
+
arguments and argument-count enforcement. Date bumped.
|
|
86
|
+
9. ~ EXAMPLES.md: The finance section's composition blocks (all three Namo stages) corrected
|
|
87
|
+
from max_by, which returns a Row, to sort_by + last(1), which returns the Namo the 0.14.0
|
|
88
|
+
block contract requires — the 1.x stage, parameterised sma included, now runs end to end
|
|
89
|
+
against this release. The finance highlight notes the parameterised formula as shipped
|
|
90
|
+
(0.17.0). Date bumped.
|
|
91
|
+
10. ~ Namo::VERSION: /0.16.0/0.17.0/
|
|
92
|
+
|
|
4
93
|
20260612
|
|
5
94
|
0.16.0: ~ data/formula exclusivity — projection drops the formulae it materialises; * and ** raise on a data/formula name collision.
|
|
6
95
|
|
data/README.md
CHANGED
|
@@ -686,6 +686,36 @@ One-arity formulae are unchanged, and the two forms mix freely — a one-arity f
|
|
|
686
686
|
|
|
687
687
|
Resolving a two-arity formula needs a Namo to window over. A `Row` constructed directly, without one, raises an `ArgumentError` naming the formula rather than letting the missing context surface as an unrelated error.
|
|
688
688
|
|
|
689
|
+
#### Parameterised formulae
|
|
690
|
+
|
|
691
|
+
A formula can declare parameters beyond `(row, namo)`. The arguments arrive at access time, through `Row#[]`, so one definition serves every column and every setting:
|
|
692
|
+
|
|
693
|
+
```ruby
|
|
694
|
+
prices[:sma] = proc do |row, namo, field, period|
|
|
695
|
+
window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}].last(period)
|
|
696
|
+
window.sum{|r| r[field]} / window.count.to_f
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
prices.last[:sma, :close, 20] # 20-period moving average of close
|
|
700
|
+
prices.last[:sma, :volume, 50] # 50-period moving average of volume
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
The number of *required* parameters decides a formula's calling convention. One means row-scoped, two or more means collection-scoped, and everything past the second receives the arguments given at the call site. A trailing splat or optional after `(row, namo)` makes the arguments optional — `proc{|row, namo, *fields|}` accepts any number, including none. A proc whose second parameter is optional (`->(row, namo = nil){...}`) requires only one, so it stays row-scoped.
|
|
704
|
+
|
|
705
|
+
Argument counts are enforced. Asking with the wrong number — too few for the formula's parameters, too many for a fixed-arity proc, or any at all for a data dimension or an unparameterised formula — raises an `ArgumentError` stating the counts, rather than letting `nil` flow into the formula body:
|
|
706
|
+
|
|
707
|
+
```ruby
|
|
708
|
+
prices.last[:sma] # ArgumentError: wrong number of arguments for :sma (given 0, expected 2)
|
|
709
|
+
prices.last[:close, 20] # ArgumentError: wrong number of arguments for :close (given 1, expected 0)
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
A formula that requires arguments can't be materialised without them. `values(:sma)`, `coordinates(:sma)`, naming `:sma` in a projection, and selecting on it all raise the same `ArgumentError`; the no-argument `values`, `coordinates`, and `to_h` omit the dimension, returning everything that can be materialised. `dimensions` and `derived_dimensions` still list it — it is queryable, with arguments. To materialise particular values, bind the arguments in a one-arity wrapper and ask for that:
|
|
713
|
+
|
|
714
|
+
```ruby
|
|
715
|
+
prices[:sma_close_20] = proc{|row| row[:sma, :close, 20]}
|
|
716
|
+
prices[:date, :sma_close_20] # materialises per the usual projection rule
|
|
717
|
+
```
|
|
718
|
+
|
|
689
719
|
### Polymorphic `[]=`
|
|
690
720
|
|
|
691
721
|
`[]=` dispatches on the type of the value assigned. A proc registers a formula, as above. Anything else broadcasts the value to every row:
|
|
@@ -895,6 +925,121 @@ end
|
|
|
895
925
|
|
|
896
926
|
`super` with no parentheses forwards every argument — positional and keyword — to `Namo#initialize` unchanged. The `return unless name` guard means a subclass need not override every operator to stop the result of `*` or `select` from re-running its construction side effects: it guards on `name` instead.
|
|
897
927
|
|
|
928
|
+
### Collections
|
|
929
|
+
|
|
930
|
+
`Namo::Collection` is a hierarchical aggregate — a Namo that holds an Array of named Namos (its `members`) and exposes summary and detail views across them. It is the first member of the Namo family beyond `Namo` itself.
|
|
931
|
+
|
|
932
|
+
The motivating case is a hierarchical budget. Each sub-assembly of a car (`powertrain`, `chassis`, `body`, ...) is a Namo with shared columns; the whole car is a `Collection` of those sub-assemblies, queryable both at summary level ("weight by assembly") and detail level ("every line item across all assemblies"):
|
|
933
|
+
|
|
934
|
+
```ruby
|
|
935
|
+
class Car < Namo::Collection
|
|
936
|
+
def summary(dimension, by: :assembly, reducer: :sum)
|
|
937
|
+
super
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
def detail(by: :assembly)
|
|
941
|
+
super
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
class SubAssembly < Namo; end
|
|
946
|
+
|
|
947
|
+
powertrain = SubAssembly.new(name: :powertrain, data: [
|
|
948
|
+
{component: 'engine', weight: 200, cost: 50000},
|
|
949
|
+
{component: 'gearbox', weight: 80, cost: 20000}
|
|
950
|
+
])
|
|
951
|
+
chassis = SubAssembly.new(name: :chassis, data: [{component: 'frame', weight: 150, cost: 30000}])
|
|
952
|
+
body = SubAssembly.new(name: :body, data: [{component: 'panels', weight: 60, cost: 15000}])
|
|
953
|
+
|
|
954
|
+
gt = Car.new
|
|
955
|
+
gt << [powertrain, chassis, body]
|
|
956
|
+
|
|
957
|
+
gt.summary(:weight).to_a
|
|
958
|
+
# => [
|
|
959
|
+
# {assembly: :powertrain, weight: 280},
|
|
960
|
+
# {assembly: :chassis, weight: 150},
|
|
961
|
+
# {assembly: :body, weight: 60}
|
|
962
|
+
# ]
|
|
963
|
+
|
|
964
|
+
gt.summary(:weight).values(:weight).sum # total weight by summing the assembly summaries
|
|
965
|
+
# => 490
|
|
966
|
+
|
|
967
|
+
gt.detail.values(:weight).sum # total weight by summing every line item
|
|
968
|
+
# => 490
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
`Car` overrides `summary`/`detail` only to set `by: :assembly` as the per-class default and then calls `super`. A bare `Namo::Collection.new` works equally well, defaulting `by:` to `:member` and taking it at the call site.
|
|
972
|
+
|
|
973
|
+
#### Lazy detail, behaving as its line items
|
|
974
|
+
|
|
975
|
+
A Collection's substance is its `members`; the inherited `@data` is a *derived view* of them. Any inherited row-operation — selection, projection, `each`, `values`, the set and composition operators — reads that view, so a Collection transparently behaves as its **detail** (the lossless union of its members' rows). Nothing has to be called first:
|
|
976
|
+
|
|
977
|
+
```ruby
|
|
978
|
+
gt.values(:weight)
|
|
979
|
+
# => [200, 80, 150, 60]
|
|
980
|
+
|
|
981
|
+
gt[component: 'engine'].values(:cost)
|
|
982
|
+
# => [50000]
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
Detail is the lazy view because a Collection's rows simply *are* its members' rows; a summary is a reduction you pose against them, so it is never reached by accident — only through `summary` or `as_summary`.
|
|
986
|
+
|
|
987
|
+
#### Four view methods
|
|
988
|
+
|
|
989
|
+
The views come in a non-mutating pair and a mutating pair:
|
|
990
|
+
|
|
991
|
+
- `summary(dimension, by:, reducer:)` and `detail(by:)` are **non-mutating** — each returns a fresh `Namo` derived from the members, leaving the Collection untouched. Use these when you want a view to keep: assign the result to a variable and operate on it independently.
|
|
992
|
+
- `as_summary(dimension, by:, reducer:)` and `as_detail(by)` are **mutating** — each sets the Collection's data to the chosen view and returns `self`, for a fluent step. (`as_detail` carries no `dimension`, so its label argument is positional: `as_detail(:assembly)`.)
|
|
993
|
+
|
|
994
|
+
```ruby
|
|
995
|
+
gt.summary(:cost, reducer: :mean) # a fresh Namo; gt is unchanged
|
|
996
|
+
gt.as_summary(:weight) # gt's data becomes the summary; returns gt
|
|
997
|
+
gt.as_detail(:assembly) # gt's data becomes the detail; returns gt
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
`reducer:` is any method the member's column responds to — `:sum` (the default) and `:mean` are typical (`:mean` via a statistics gem that adds `Array#mean`).
|
|
1001
|
+
|
|
1002
|
+
#### Inject-iff-absent
|
|
1003
|
+
|
|
1004
|
+
`detail(by:)` unions the members' rows and labels each with its origin, but only when that label isn't already present:
|
|
1005
|
+
|
|
1006
|
+
- If `by` is **already a dimension** in a member's rows, the row passes through untouched — the dimension is intrinsic.
|
|
1007
|
+
- If `by` is **not** present, `detail` injects it (`row.merge(by => member.name)`), promoting the member's name into a dimension.
|
|
1008
|
+
|
|
1009
|
+
This single conditional is where assembly (`<<`, members named extrinsically) and partition (`group_by`, members named by an intrinsic value — 0.19.0) meet. For an assembled Collection, `as_detail(:assembly)` is the dimension-creating step: it promotes the member name into real data and **retains** it. From then on the structure is intrinsic and round-trips are exact; the promoted dimension is removed only by explicit contraction (`gt[-:assembly]`), never automatically.
|
|
1010
|
+
|
|
1011
|
+
#### `<<` and unnamed members
|
|
1012
|
+
|
|
1013
|
+
`<<` accepts a single member or an array of them. A member whose `name` collides with an existing member's **replaces** it (last-write-wins), making the name → member mapping a dictionary rather than a multimap:
|
|
1014
|
+
|
|
1015
|
+
```ruby
|
|
1016
|
+
gt << SubAssembly.new(name: :powertrain, data: [...]) # replaces the existing :powertrain
|
|
1017
|
+
gt << [front_suspension, rear_suspension] # adds each
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
There is no insertion-time guard against unnamed members. An unnamed member is simply appended (no name to collide on) and is unfindable by `find` — the honest consequence of having no name, not an error. `find(name)` returns the member with that name, or `nil`:
|
|
1021
|
+
|
|
1022
|
+
```ruby
|
|
1023
|
+
gt.find(:chassis) # => the chassis SubAssembly
|
|
1024
|
+
gt.find(:missing) # => nil
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
(`find(name)` is member lookup; it shadows `Enumerable#find` on Collections. Predicate search over rows remains available as `detect`.)
|
|
1028
|
+
|
|
1029
|
+
#### View lifetime and liveness
|
|
1030
|
+
|
|
1031
|
+
Materialisation is pure-live: the Collection rebuilds its data view from the current members on every `<<`, with no memoisation. So a mutation is reflected immediately — add a member, then summarise or detail, and the new member is included.
|
|
1032
|
+
|
|
1033
|
+
A mutating `as_summary`/`as_detail` view **persists until the next `<<`**, which re-materialises detail. So `as_summary` is for "be the summary for this immediate chain":
|
|
1034
|
+
|
|
1035
|
+
```ruby
|
|
1036
|
+
gt.as_summary(:weight).values(:weight) # => [280, 150, 60] (the summary)
|
|
1037
|
+
gt << front_suspension # re-materialises detail
|
|
1038
|
+
gt.values(:weight) # => [200, 80, 150, 60, ...] (line items again)
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
Freeze-gated memoisation is a 2.x optimisation — opt-in via `freeze`, transparent, and never changing this observable behaviour. `group_by` (0.19.0) is the partition-side constructor for the same type: it splits a Namo into a `Collection`, the mirror of assembling one with `<<`.
|
|
1042
|
+
|
|
898
1043
|
## Why?
|
|
899
1044
|
|
|
900
1045
|
Every other multi-dimensional array library requires you to pre-shape your data before you can work with it. Namo takes it in the form it likely already comes in.
|
data/Rakefile
CHANGED
|
@@ -21,7 +21,7 @@ namespace :docs do
|
|
|
21
21
|
task :md2pdf => :md4print do
|
|
22
22
|
Dir.glob('docs/*.print.md').each do |f|
|
|
23
23
|
pdf = f.sub(/\.md$/, '.pdf')
|
|
24
|
-
sh "pandoc #{f} --pdf-engine=xelatex -V geometry:margin=1in -V mainfont=Charter -V monofont=Menlo -o #{pdf}"
|
|
24
|
+
sh "pandoc #{f} --pdf-engine=xelatex --include-in-header script/print.preamble.tex -V geometry:margin=1in -V mainfont=Charter -V monofont=Menlo -o #{pdf}"
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Namo/Collection.rb
|
|
2
|
+
# Namo::Collection
|
|
3
|
+
|
|
4
|
+
class Namo
|
|
5
|
+
class Collection < Namo
|
|
6
|
+
attr_reader :members
|
|
7
|
+
|
|
8
|
+
def <<(*members)
|
|
9
|
+
members.flatten.each do |member|
|
|
10
|
+
@members.reject!{|existing| existing.name == member.name} unless member.name.nil?
|
|
11
|
+
@members << member
|
|
12
|
+
end
|
|
13
|
+
@data = detail.data
|
|
14
|
+
self
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def find(name)
|
|
18
|
+
@members.find{|member| member.name == name} unless name.nil?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def summary(dimension, by: :member, reducer: :sum)
|
|
22
|
+
rows = @members.map do |member|
|
|
23
|
+
{by => member.name, dimension => member.values(dimension).send(reducer)}
|
|
24
|
+
end
|
|
25
|
+
Namo.new(rows)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def detail(by: :member)
|
|
29
|
+
rows = @members.flat_map do |member|
|
|
30
|
+
member.data.map{|row| row.key?(by) ? row : row.merge(by => member.name)}
|
|
31
|
+
end
|
|
32
|
+
Namo.new(rows)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def as_summary(dimension, by: :member, reducer: :sum)
|
|
36
|
+
@data = summary(dimension, by: by, reducer: reducer).data
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def as_detail(by = :member)
|
|
41
|
+
@data = detail(by: by).data
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def initialize(positional_data = nil, data: [], formulae: {}, name: nil)
|
|
48
|
+
@members = []
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/Namo/Row.rb
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
class Namo
|
|
5
5
|
class Row
|
|
6
|
-
def [](name)
|
|
6
|
+
def [](name, *arguments)
|
|
7
|
+
raise_unless_expected_arguments(name, arguments)
|
|
7
8
|
if @formulae.key?(name)
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
formula = @formulae[name]
|
|
10
|
+
if collection_scoped?(formula)
|
|
10
11
|
raise_unless_namo_context(name)
|
|
11
|
-
|
|
12
|
+
formula.call(self, @namo, *arguments)
|
|
12
13
|
else
|
|
13
|
-
|
|
14
|
+
formula.call(self)
|
|
14
15
|
end
|
|
15
16
|
else
|
|
16
17
|
@row[name]
|
|
@@ -56,9 +57,32 @@ class Namo
|
|
|
56
57
|
@namo = namo
|
|
57
58
|
end
|
|
58
59
|
|
|
60
|
+
def collection_scoped?(formula)
|
|
61
|
+
required_parameter_count(formula) >= 2
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def required_parameter_count(formula)
|
|
65
|
+
formula.arity >= 0 ? formula.arity : -formula.arity - 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def expected_argument_counts(name)
|
|
69
|
+
formula = @formulae[name]
|
|
70
|
+
return [0, 0] unless formula && collection_scoped?(formula)
|
|
71
|
+
minimum = required_parameter_count(formula) - 2
|
|
72
|
+
maximum = formula.arity >= 0 ? minimum : nil
|
|
73
|
+
[minimum, maximum]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def raise_unless_expected_arguments(name, arguments)
|
|
77
|
+
minimum, maximum = expected_argument_counts(name)
|
|
78
|
+
return if arguments.length >= minimum && (maximum.nil? || arguments.length <= maximum)
|
|
79
|
+
expected = maximum.nil? ? "#{minimum}+" : minimum.to_s
|
|
80
|
+
raise ArgumentError, "wrong number of arguments for #{name.inspect} (given #{arguments.length}, expected #{expected})"
|
|
81
|
+
end
|
|
82
|
+
|
|
59
83
|
def raise_unless_namo_context(name)
|
|
60
84
|
unless @namo
|
|
61
|
-
raise ArgumentError, "
|
|
85
|
+
raise ArgumentError, "collection-scoped formula #{name.inspect} requires a Namo context, but this Row has none"
|
|
62
86
|
end
|
|
63
87
|
end
|
|
64
88
|
end
|
data/lib/Namo/VERSION.rb
CHANGED
data/lib/namo.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require_relative './Namo/NegatedDimension'
|
|
5
5
|
require_relative './Namo/Row'
|
|
6
|
+
require_relative './Namo/Collection'
|
|
6
7
|
require_relative './Namo/Enumerable'
|
|
7
8
|
require_relative './Namo/VERSION'
|
|
8
9
|
require_relative './Symbol'
|
|
@@ -28,7 +29,7 @@ class Namo
|
|
|
28
29
|
|
|
29
30
|
def values(*dims)
|
|
30
31
|
if dims.empty?
|
|
31
|
-
|
|
32
|
+
materialisable_dimensions.each_with_object({}){|dim, hash| hash[dim] = values_for(dim)}
|
|
32
33
|
elsif dims.length == 1
|
|
33
34
|
values_for(dims.first)
|
|
34
35
|
else
|
|
@@ -246,6 +247,19 @@ class Namo
|
|
|
246
247
|
end
|
|
247
248
|
end
|
|
248
249
|
|
|
250
|
+
def materialisable_dimensions
|
|
251
|
+
dimensions.reject{|dim| requires_arguments?(dim)}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def requires_arguments?(name)
|
|
255
|
+
formula = @formulae[name]
|
|
256
|
+
!!formula && required_parameter_count(formula) > 2
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def required_parameter_count(formula)
|
|
260
|
+
formula.arity >= 0 ? formula.arity : -formula.arity - 1
|
|
261
|
+
end
|
|
262
|
+
|
|
249
263
|
def raise_unless_namo(other)
|
|
250
264
|
unless other.is_a?(Namo)
|
|
251
265
|
raise TypeError, "can't compare Namo with #{other.class}"
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
require 'minitest/autorun'
|
|
2
|
+
require 'minitest-spec-context'
|
|
3
|
+
|
|
4
|
+
require_relative '../../lib/namo'
|
|
5
|
+
|
|
6
|
+
class Array
|
|
7
|
+
def mean
|
|
8
|
+
sum.to_f / size
|
|
9
|
+
end
|
|
10
|
+
end unless [].respond_to?(:mean)
|
|
11
|
+
|
|
12
|
+
class SubAssembly < Namo; end
|
|
13
|
+
|
|
14
|
+
class Car < Namo::Collection
|
|
15
|
+
def summary(dimension, by: :assembly, reducer: :sum)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def detail(by: :assembly)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe Namo::Collection do
|
|
25
|
+
let(:powertrain) do
|
|
26
|
+
SubAssembly.new(name: :powertrain, data: [
|
|
27
|
+
{component: 'engine', weight: 200, cost: 50000},
|
|
28
|
+
{component: 'gearbox', weight: 80, cost: 20000},
|
|
29
|
+
])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
let(:chassis) do
|
|
33
|
+
SubAssembly.new(name: :chassis, data: [{component: 'frame', weight: 150, cost: 30000}])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
let(:body) do
|
|
37
|
+
SubAssembly.new(name: :body, data: [{component: 'panels', weight: 60, cost: 15000}])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
let(:wheels) do
|
|
41
|
+
SubAssembly.new(name: :wheels, data: [{component: 'tyres', weight: 40, cost: 8000}])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
let(:collection) do
|
|
45
|
+
Namo::Collection.new.tap{|c| c << [powertrain, chassis, body]}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe "construction" do
|
|
49
|
+
it "starts with empty members" do
|
|
50
|
+
_(Namo::Collection.new.members).must_equal []
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe "lazy detail materialisation" do
|
|
55
|
+
it "materialises detail on a bare row-operation without a prior as_detail" do
|
|
56
|
+
_(collection.values(:weight)).must_equal [200, 80, 150, 60]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "supports selection against the materialised detail" do
|
|
60
|
+
_(collection[component: 'engine'].values(:component)).must_equal ['engine']
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "reflects a newly added member on the next operation" do
|
|
64
|
+
before = collection.values(:weight)
|
|
65
|
+
collection << wheels
|
|
66
|
+
_(collection.values(:weight)).must_equal before + [40]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe "#<<" do
|
|
71
|
+
it "adds a member" do
|
|
72
|
+
collection = Namo::Collection.new
|
|
73
|
+
collection << powertrain
|
|
74
|
+
_(collection.members.size).must_equal 1
|
|
75
|
+
_(collection.find(:powertrain)).must_be_same_as powertrain
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "replaces a member with a colliding name (last-write-wins)" do
|
|
79
|
+
collection = Namo::Collection.new
|
|
80
|
+
collection << powertrain
|
|
81
|
+
replacement = SubAssembly.new(name: :powertrain, data: [{component: 'hybrid', weight: 250, cost: 70000}])
|
|
82
|
+
collection << replacement
|
|
83
|
+
_(collection.members.size).must_equal 1
|
|
84
|
+
_(collection.find(:powertrain)).must_be_same_as replacement
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "adds each member from an array" do
|
|
88
|
+
collection = Namo::Collection.new
|
|
89
|
+
collection << [powertrain, chassis]
|
|
90
|
+
_(collection.members.size).must_equal 2
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "appends an unnamed member, which is unfindable by name" do
|
|
94
|
+
collection = Namo::Collection.new
|
|
95
|
+
collection << SubAssembly.new(data: [{component: 'misc', weight: 5, cost: 100}])
|
|
96
|
+
collection << SubAssembly.new(data: [{component: 'other', weight: 6, cost: 200}])
|
|
97
|
+
_(collection.members.size).must_equal 2
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "returns self" do
|
|
101
|
+
collection = Namo::Collection.new
|
|
102
|
+
_(collection << powertrain).must_be_same_as collection
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe "#find" do
|
|
107
|
+
it "returns the member with the given name" do
|
|
108
|
+
_(collection.find(:chassis)).must_be_same_as chassis
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "returns nil for an absent name" do
|
|
112
|
+
_(collection.find(:engine)).must_be_nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "returns nil for find(nil)" do
|
|
116
|
+
_(collection.find(nil)).must_be_nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "never matches an unnamed member" do
|
|
120
|
+
collection = Namo::Collection.new
|
|
121
|
+
collection << SubAssembly.new(data: [{component: 'misc', weight: 5, cost: 100}])
|
|
122
|
+
_(collection.find(nil)).must_be_nil
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
describe "#summary" do
|
|
127
|
+
it "reduces each member to a labelled row" do
|
|
128
|
+
summary = collection.summary(:weight)
|
|
129
|
+
_(summary.values(:member)).must_equal [:powertrain, :chassis, :body]
|
|
130
|
+
_(summary.values(:weight)).must_equal [280, 150, 60]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "labels with a custom by dimension" do
|
|
134
|
+
summary = collection.summary(:weight, by: :assembly)
|
|
135
|
+
_(summary.values(:assembly)).must_equal [:powertrain, :chassis, :body]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "reduces with a custom reducer" do
|
|
139
|
+
summary = collection.summary(:weight, reducer: :mean)
|
|
140
|
+
_(summary.values(:weight)).must_equal [140.0, 150.0, 60.0]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "is non-mutating — leaves the collection's data untouched" do
|
|
144
|
+
collection.summary(:weight)
|
|
145
|
+
_(collection.values(:weight)).must_equal [200, 80, 150, 60]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
describe "#detail" do
|
|
150
|
+
it "returns a plain Namo" do
|
|
151
|
+
_(collection.detail).must_be_instance_of Namo
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "unions the members' rows, injecting the by dimension when absent" do
|
|
155
|
+
detail = collection.detail
|
|
156
|
+
_(detail.values(:member)).must_equal [:powertrain, :powertrain, :chassis, :body]
|
|
157
|
+
_(detail.values(:weight)).must_equal [200, 80, 150, 60]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "does not inject when the by dimension is already present" do
|
|
161
|
+
collection = Namo::Collection.new
|
|
162
|
+
collection << SubAssembly.new(name: :ignored, data: [{member: :preexisting, weight: 5}])
|
|
163
|
+
_(collection.detail.values(:member)).must_equal [:preexisting]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "is non-mutating — leaves the collection's data untouched" do
|
|
167
|
+
collection.detail(by: :assembly)
|
|
168
|
+
_(collection.dimensions).wont_include :assembly
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describe "live recomputation (no memoisation in 1.x)" do
|
|
173
|
+
it "reflects a mutation on the next detail call" do
|
|
174
|
+
before = collection.detail.values(:weight).size
|
|
175
|
+
collection << wheels
|
|
176
|
+
_(collection.detail.values(:weight).size).must_equal before + 1
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "reflects a mutation on the next summary call" do
|
|
180
|
+
collection << wheels
|
|
181
|
+
_(collection.summary(:weight).values(:member)).must_equal [:powertrain, :chassis, :body, :wheels]
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
describe "#as_summary / #as_detail" do
|
|
186
|
+
it "as_summary sets the data to the summary view and returns self" do
|
|
187
|
+
result = collection.as_summary(:weight)
|
|
188
|
+
_(result).must_be_same_as collection
|
|
189
|
+
_(collection.values(:weight)).must_equal [280, 150, 60]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "as_detail sets the data to the detail view and returns self" do
|
|
193
|
+
collection.as_summary(:weight)
|
|
194
|
+
result = collection.as_detail
|
|
195
|
+
_(result).must_be_same_as collection
|
|
196
|
+
_(collection.values(:weight)).must_equal [200, 80, 150, 60]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it "exposes the summary's columns in dimensions immediately after as_summary" do
|
|
200
|
+
collection.as_summary(:weight)
|
|
201
|
+
_(collection.dimensions.sort).must_equal [:member, :weight].sort
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
describe "as_* view lifetime (rebuild-on-<<: persists until the next <<)" do
|
|
206
|
+
it "keeps the summary view across a bare row-operation" do
|
|
207
|
+
collection.as_summary(:weight)
|
|
208
|
+
_(collection.values(:weight)).must_equal [280, 150, 60]
|
|
209
|
+
_(collection[member: :powertrain].values(:weight)).must_equal [280]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it "re-materialises detail on the next <<" do
|
|
213
|
+
collection.as_summary(:weight)
|
|
214
|
+
collection << wheels
|
|
215
|
+
_(collection.values(:weight)).must_equal [200, 80, 150, 60, 40]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
describe "assembly round-trip" do
|
|
220
|
+
it "injects :assembly via as_detail and retains it through a later << and as_detail" do
|
|
221
|
+
collection.as_detail(:assembly)
|
|
222
|
+
_(collection.dimensions).must_include :assembly
|
|
223
|
+
_(collection.coordinates(:assembly)).must_equal [:powertrain, :chassis, :body]
|
|
224
|
+
collection << wheels
|
|
225
|
+
collection.as_detail(:assembly)
|
|
226
|
+
_(collection.dimensions).must_include :assembly
|
|
227
|
+
_(collection.coordinates(:assembly)).must_equal [:powertrain, :chassis, :body, :wheels]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it "removes :assembly only by explicit contraction" do
|
|
231
|
+
collection.as_detail(:assembly)
|
|
232
|
+
contracted = collection[-:assembly]
|
|
233
|
+
_(contracted.dimensions).wont_include :assembly
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
describe "subclass with a default by:" do
|
|
238
|
+
let(:car) do
|
|
239
|
+
Car.new.tap{|c| c << [powertrain, chassis, body]}
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it "uses the subclass default :assembly for summary" do
|
|
243
|
+
_(car.summary(:weight).values(:assembly)).must_equal [:powertrain, :chassis, :body]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it "uses the subclass default :assembly for detail" do
|
|
247
|
+
_(car.detail.values(:assembly)).must_equal [:powertrain, :powertrain, :chassis, :body]
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
data/test/Namo/Row_test.rb
CHANGED
|
@@ -94,6 +94,111 @@ describe Namo::Row do
|
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
describe "#[] parameterised formulae" do
|
|
98
|
+
let(:namo) do
|
|
99
|
+
Namo.new([row_data])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
let(:contextual_row) do
|
|
103
|
+
Namo::Row.new(row_data, formulae, namo)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "calls an arity-3 formula with the Row, the yielding Namo, and one argument" do
|
|
107
|
+
seen = nil
|
|
108
|
+
formulae[:scaled] = ->(r, n, factor){seen = [r, n, factor]; r[:price] * factor}
|
|
109
|
+
_(contextual_row[:scaled, 3]).must_equal 30.0
|
|
110
|
+
_(seen[0]).must_be_same_as contextual_row
|
|
111
|
+
_(seen[1].equal?(namo)).must_equal true
|
|
112
|
+
_(seen[2]).must_equal 3
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "calls an arity-4 formula with two arguments" do
|
|
116
|
+
formulae[:metric] = ->(r, n, field, factor){r[field] * factor}
|
|
117
|
+
_(contextual_row[:metric, :quantity, 2]).must_equal 200
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "forwards a trailing splat's arguments past a required one (arity -4)" do
|
|
121
|
+
formulae[:dim] = proc{|r, n, field, *rest| [field, rest]}
|
|
122
|
+
_(contextual_row[:dim, :price]).must_equal [:price, []]
|
|
123
|
+
_(contextual_row[:dim, :price, 1, 2]).must_equal [:price, [1, 2]]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "treats a splat directly after namo as collection-scoped taking any number of arguments (arity -3)" do
|
|
127
|
+
formulae[:dim] = proc{|r, n, *rest| rest}
|
|
128
|
+
_(contextual_row[:dim]).must_equal []
|
|
129
|
+
_(contextual_row[:dim, 1, 2, 3]).must_equal [1, 2, 3]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "keeps a one-required-parameter proc row-scoped regardless of trailing optionals" do
|
|
133
|
+
seen = :unset
|
|
134
|
+
formulae[:dim] = ->(r, n = :fallback){seen = n; 1}
|
|
135
|
+
contextual_row[:dim]
|
|
136
|
+
_(seen).must_equal :fallback
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "lets a row-scoped formula call a parameterised formula with arguments" do
|
|
140
|
+
formulae[:metric] = ->(r, n, field, factor){r[field] * factor}
|
|
141
|
+
formulae[:double_quantity] = ->(r){r[:metric, :quantity, 2]}
|
|
142
|
+
_(contextual_row[:double_quantity]).must_equal 200
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "raises ArgumentError naming the formula when a parameterised formula has no Namo context" do
|
|
146
|
+
formulae[:metric] = ->(r, n, field){r[field]}
|
|
147
|
+
error = _(proc{row[:metric, :price]}).must_raise ArgumentError
|
|
148
|
+
_(error.message).must_match(/metric/)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
describe "#[] argument-count enforcement" do
|
|
153
|
+
let(:namo) do
|
|
154
|
+
Namo.new([row_data])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
let(:contextual_row) do
|
|
158
|
+
Namo::Row.new(row_data, formulae, namo)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "raises when a parameterised formula is given too few arguments" do
|
|
162
|
+
formulae[:metric] = ->(r, n, field, period){r[field] * period}
|
|
163
|
+
error = _(proc{contextual_row[:metric, :price]}).must_raise ArgumentError
|
|
164
|
+
_(error.message).must_equal "wrong number of arguments for :metric (given 1, expected 2)"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it "raises when a fixed-arity parameterised formula is given too many arguments" do
|
|
168
|
+
formulae[:metric] = ->(r, n, field){r[field]}
|
|
169
|
+
error = _(proc{contextual_row[:metric, :price, 20]}).must_raise ArgumentError
|
|
170
|
+
_(error.message).must_equal "wrong number of arguments for :metric (given 2, expected 1)"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "raises when a splatted parameterised formula is given fewer than its required arguments" do
|
|
174
|
+
formulae[:metric] = proc{|r, n, field, *rest| r[field]}
|
|
175
|
+
error = _(proc{contextual_row[:metric]}).must_raise ArgumentError
|
|
176
|
+
_(error.message).must_equal "wrong number of arguments for :metric (given 0, expected 1+)"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "raises when arguments are given for a data dimension" do
|
|
180
|
+
error = _(proc{row[:price, 20]}).must_raise ArgumentError
|
|
181
|
+
_(error.message).must_equal "wrong number of arguments for :price (given 1, expected 0)"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "raises when arguments are given for a row-scoped formula" do
|
|
185
|
+
formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
186
|
+
error = _(proc{row[:revenue, 20]}).must_raise ArgumentError
|
|
187
|
+
_(error.message).must_equal "wrong number of arguments for :revenue (given 1, expected 0)"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "raises when arguments are given for a two-arity formula" do
|
|
191
|
+
formulae[:row_count] = ->(r, n){n.count}
|
|
192
|
+
error = _(proc{contextual_row[:row_count, 1]}).must_raise ArgumentError
|
|
193
|
+
_(error.message).must_equal "wrong number of arguments for :row_count (given 1, expected 0)"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "raises when arguments are given for a missing dimension" do
|
|
197
|
+
error = _(proc{row[:missing, 1]}).must_raise ArgumentError
|
|
198
|
+
_(error.message).must_equal "wrong number of arguments for :missing (given 1, expected 0)"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
97
202
|
describe "#match?" do
|
|
98
203
|
it "matches a single value" do
|
|
99
204
|
_(row.match?(product: 'Widget')).must_equal true
|
data/test/namo_test.rb
CHANGED
|
@@ -797,6 +797,129 @@ describe Namo do
|
|
|
797
797
|
end
|
|
798
798
|
end
|
|
799
799
|
|
|
800
|
+
describe "#[]= parameterised formulae" do
|
|
801
|
+
let(:price_data) do
|
|
802
|
+
[
|
|
803
|
+
{symbol: 'AAA', date: 1, close: 10.0, volume: 100},
|
|
804
|
+
{symbol: 'AAA', date: 2, close: 20.0, volume: 200},
|
|
805
|
+
{symbol: 'AAA', date: 3, close: 30.0, volume: 300},
|
|
806
|
+
]
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
let(:prices) do
|
|
810
|
+
Namo.new(price_data)
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
# A parameterised moving average: the field and the window length arrive at
|
|
814
|
+
# access time, so one definition serves every column and every period.
|
|
815
|
+
let(:sma) do
|
|
816
|
+
proc do |row, namo, field, period|
|
|
817
|
+
window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}].last(period)
|
|
818
|
+
window.sum{|r| r[field]} / window.count.to_f
|
|
819
|
+
end
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
it "resolves with arguments through a yielded Row" do
|
|
823
|
+
prices[:sma] = sma
|
|
824
|
+
_(prices.first[:sma, :close, 2]).must_equal 10.0
|
|
825
|
+
_(prices.last[:sma, :close, 2]).must_equal 25.0
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
it "serves different fields and periods from one definition" do
|
|
829
|
+
prices[:sma] = sma
|
|
830
|
+
_(prices.last[:sma, :close, 3]).must_equal 20.0
|
|
831
|
+
_(prices.last[:sma, :volume, 2]).must_equal 250.0
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
it "resolves inside an Enumerable predicate" do
|
|
835
|
+
prices[:sma] = sma
|
|
836
|
+
result = prices.select{|row| row[:sma, :close, 2] > 12.0}
|
|
837
|
+
_(result).must_be_kind_of Namo
|
|
838
|
+
_(result.values(:date)).must_equal [2, 3]
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
it "lets a one-arity formula reference a parameterised formula with arguments" do
|
|
842
|
+
prices[:sma] = sma
|
|
843
|
+
prices[:rising] = proc{|row| row[:sma, :close, 1] > row[:sma, :close, 3]}
|
|
844
|
+
_(prices.values(:rising)).must_equal [false, true, true]
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
it "lists a parameterised dimension in dimensions and derived_dimensions" do
|
|
848
|
+
prices[:sma] = sma
|
|
849
|
+
_(prices.dimensions).must_equal [:symbol, :date, :close, :volume, :sma]
|
|
850
|
+
_(prices.derived_dimensions).must_equal [:sma]
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
it "omits a parameterised dimension from the no-arg values, coordinates, and to_h" do
|
|
854
|
+
prices[:sma] = sma
|
|
855
|
+
_(prices.values.keys).must_equal [:symbol, :date, :close, :volume]
|
|
856
|
+
_(prices.coordinates.keys).must_equal [:symbol, :date, :close, :volume]
|
|
857
|
+
_(prices.to_h.keys).must_equal [:symbol, :date, :close, :volume]
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
it "raises when values is asked for a parameterised dimension by name" do
|
|
861
|
+
prices[:sma] = sma
|
|
862
|
+
error = _(proc{prices.values(:sma)}).must_raise ArgumentError
|
|
863
|
+
_(error.message).must_equal "wrong number of arguments for :sma (given 0, expected 2)"
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
it "raises when coordinates is asked for a parameterised dimension by name" do
|
|
867
|
+
prices[:sma] = sma
|
|
868
|
+
_(proc{prices.coordinates(:sma)}).must_raise ArgumentError
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
it "raises when a projection names a parameterised dimension" do
|
|
872
|
+
prices[:sma] = sma
|
|
873
|
+
_(proc{prices[:date, :sma]}).must_raise ArgumentError
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
it "raises when a selection selects on a parameterised dimension" do
|
|
877
|
+
prices[:sma] = sma
|
|
878
|
+
_(proc{prices[sma: ->(v){v > 12.0}]}).must_raise ArgumentError
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
it "carries a parameterised formula through contraction" do
|
|
882
|
+
prices[:sma] = sma
|
|
883
|
+
contracted = prices[-:volume]
|
|
884
|
+
_(contracted.derived_dimensions).must_equal [:sma]
|
|
885
|
+
_(contracted.first[:sma, :close, 2]).must_equal 10.0
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
it "carries a parameterised formula through selection, windowing over the filtered rows" do
|
|
889
|
+
prices[:sma] = sma
|
|
890
|
+
filtered = prices[date: 2..3]
|
|
891
|
+
_(filtered.first[:sma, :close, 2]).must_equal 20.0
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
it "materialises through a one-arity wrapper that binds the arguments" do
|
|
895
|
+
prices[:sma] = sma
|
|
896
|
+
prices[:sma_close_2] = proc{|row| row[:sma, :close, 2]}
|
|
897
|
+
_(prices.values(:sma_close_2)).must_equal [10.0, 15.0, 25.0]
|
|
898
|
+
_(prices[:date, :sma_close_2].values(:sma_close_2)).must_equal [10.0, 15.0, 25.0]
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
it "includes a namo-plus-splat formula in the bulk views, called with no extra arguments" do
|
|
902
|
+
prices[:flexible] = proc{|row, namo, *rest| rest.empty? ? namo.count : rest.sum}
|
|
903
|
+
_(prices.values.keys).must_include :flexible
|
|
904
|
+
_(prices.values(:flexible)).must_equal [3, 3, 3]
|
|
905
|
+
_(prices.first[:flexible, 1, 2, 4]).must_equal 7
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
it "carries a parameterised formula through a set-operator result" do
|
|
909
|
+
a = Namo.new(price_data.take(2))
|
|
910
|
+
b = Namo.new([price_data.last])
|
|
911
|
+
a[:sma] = sma
|
|
912
|
+
_((a + b).last[:sma, :close, 2]).must_equal 25.0
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
it "returns [] for a parameterised dimension on an empty Namo without invoking the formula" do
|
|
916
|
+
invoked = false
|
|
917
|
+
empty = Namo.new([], formulae: {sma: ->(row, namo, field, period){invoked = true; 0}})
|
|
918
|
+
_(empty.values(:sma)).must_equal []
|
|
919
|
+
_(invoked).must_equal false
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
|
|
800
923
|
describe "data/formula exclusivity" do
|
|
801
924
|
context "projection" do
|
|
802
925
|
let(:price_data) do
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: namo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- thoran
|
|
@@ -64,6 +64,7 @@ files:
|
|
|
64
64
|
- LICENSE
|
|
65
65
|
- README.md
|
|
66
66
|
- Rakefile
|
|
67
|
+
- lib/Namo/Collection.rb
|
|
67
68
|
- lib/Namo/Enumerable.rb
|
|
68
69
|
- lib/Namo/NegatedDimension.rb
|
|
69
70
|
- lib/Namo/Row.rb
|
|
@@ -71,6 +72,7 @@ files:
|
|
|
71
72
|
- lib/Symbol.rb
|
|
72
73
|
- lib/namo.rb
|
|
73
74
|
- namo.gemspec
|
|
75
|
+
- test/Namo/Collection_test.rb
|
|
74
76
|
- test/Namo/NegatedDimension_test.rb
|
|
75
77
|
- test/Namo/Row_test.rb
|
|
76
78
|
- test/Symbol_test.rb
|