namo 0.6.0 → 0.8.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 +32 -0
- data/README.md +119 -3
- data/Rakefile +1 -1
- data/lib/Namo/Row.rb +4 -0
- data/lib/Namo/VERSION.rb +1 -1
- data/lib/namo.rb +53 -21
- data/test/Namo/Row_test.rb +149 -0
- data/test/namo_test.rb +245 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 86f572755292e2bac9808f09adb3169cec06066a5687ec2ea6bd9354f97d592a
|
|
4
|
+
data.tar.gz: edafcc183a1a9e5fb1212bb66a8b6906b94bf8e37a7375d3f9d304e5632ddba3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b2fad7c20e6cc9909b6d770a94627a071626de5cec9b402074ccda919295781dd18c38411604e5d0adf3d2f5e80833de830be37892a8e0a5cea5323bcef1a66
|
|
7
|
+
data.tar.gz: d0e9d4ae5bbe7a4dfc217614f0fb1442750313e9db734b622b831b15873e1ce6a3a5d2e91b01c450ab795476f00b94e29aae12be0dea4aad3d37e46369b5628e
|
data/CHANGELOG
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
CHANGELOG
|
|
2
2
|
_________
|
|
3
3
|
|
|
4
|
+
20260521
|
|
5
|
+
0.8.0: + proc and regex-based selection
|
|
6
|
+
|
|
7
|
+
1. ~ Namo::Row#match?: + `when Proc` branch — calls the proc with the dimension value; truthy result selects the row. Predicate receives nil for missing or nil-valued dimensions and decides.
|
|
8
|
+
2. ~ Namo::Row#match?: + `when Regexp` branch — matches against row[dimension].to_s. nil becomes "" (matches //, not /./); Integer/Float/Date/Symbol coerce via to_s.
|
|
9
|
+
3. ~ test/Namo/Row_test.rb: + Proc-predicate tests (true/false/nil/truthy non-boolean returns, nil dimension values, composition with exact/array/range/regex, multiple proc predicates across dimensions, carry-through to formula-defined dimensions). + Regexp-predicate tests (match/no-match/case-insensitive on Strings, to_s coercion of Integer/Float/Date/Symbol/nil, composition with exact/array/range/proc, multiple regex predicates across dimensions, carry-through to formula-defined dimensions).
|
|
10
|
+
4. ~ test/namo_test.rb: + End-to-end tests for proc selection, regex selection, and mixed proc/regex selection, including composition with projection and contraction in a single call and selection on formula-defined dimensions.
|
|
11
|
+
5. ~ README.md: + Proc and regex examples in the Selection section; + paragraphs on proc semantics (truthy/nil-aware, composes with everything) and regex semantics (.to_s coercion across nil/String/Symbol/Integer/Float/Date).
|
|
12
|
+
6. ~ ROADMAP.md: Promote 0.8.0 from upcoming to shipped under "Current state: 0.8.0"; revise Summary to include proc/regex in the selection vocabulary and point "next phase" at 0.9.0+.
|
|
13
|
+
7. ~ CHANGELOG: Update with 0.8.0's changes and retroactively log the 0.7.0 changes to README (+ Coordinates and values section), COMPARISON.md, and EXAMPLES.md (2 sections relocated from EXAMPLES.md to COMPARISON.md).
|
|
14
|
+
8. ~ Namo::VERSION: /0.7.0/0.8.0/
|
|
15
|
+
|
|
16
|
+
20260520
|
|
17
|
+
0.7.0: + derived-dimension surfacing, lazy single-column access, live views
|
|
18
|
+
|
|
19
|
+
1. + Namo#data_dimensions: Returns the storage dimensions as a plain Array (keys of the first row).
|
|
20
|
+
2. + Namo#derived_dimensions: Returns the formula names as a plain Array.
|
|
21
|
+
3. + Namo#values(*dims): Per-dimension full sequences (duplicates preserved, in row order). With no args, returns a Hash {dim => sequence} across the queryable namespace. With one arg, lazily computes and returns just that column as an Array. With multiple args, returns a subset Hash containing each requested dimension. Unknown dimensions propagate as nil per row, matching Row#[] and Namo#[] selection conventions — values(:unknown) returns an Array of nils; values(:known, :unknown) returns {known: [...], unknown: [nil, ...]}.
|
|
22
|
+
4. + Namo#coordinates(*dims): Per-dimension unique-value sets. Same argument shape as #values; coordinates(dim) == values(dim).uniq. Unknown dimensions therefore appear as [nil].
|
|
23
|
+
5. + Namo#to_h: Alias for the full values Hash.
|
|
24
|
+
6. ~ Namo#dimensions: Now covers the queryable namespace (storage + derived) instead of storage-only. Return type stays a plain Array. Memoisation removed: every call recomputes from current state (live view).
|
|
25
|
+
7. ~ Namo#coordinates: Memoisation removed (was @coordinates ||= ...). Now covers the queryable namespace; coordinates(:derived_dim) and coordinates[:derived_dim] work, evaluating the formula across all rows. New positional-args API supports lazy single-column access.
|
|
26
|
+
8. ~ Namo#canonical_data: Sorts by data_dimensions to preserve 0.6.0 row-equality semantics under the broader dimensions definition.
|
|
27
|
+
9. /raise_unless_matching_dimensions/raise_unless_matching_data_dimensions/: Private helper renamed to reflect what it actually compares.
|
|
28
|
+
10. ~ test/namo_test.rb: + Tests for #data_dimensions, #derived_dimensions, the no-arg/single-arg/multi-arg forms of #values and #coordinates, derived-dimension surfacing in #dimensions, #to_h, the coordinates(dim) == values(dim).uniq consistency property, and live-view semantics (added rows / formulae reflected on next call).
|
|
29
|
+
11. ~ README.md: + Coordinates and values section covering #values, #coordinates(*dims), #data_dimensions, #derived_dimensions, and #to_h.
|
|
30
|
+
12. ~ COMPARISON.md: + "Worked example: comparing yesterday's screen to today's" under the set-operators section; + "Schema dispatch on incoming data feeds" under the equality/=== section. Date bumped to 20260520.
|
|
31
|
+
13. ~ EXAMPLES.md: - "Schema dispatch on incoming data feeds", - "Comparing yesterday's screen to today's" — both relocated to COMPARISON.md as worked examples under the matching feature sections. Date bumped to 20260520.
|
|
32
|
+
14. ~ ROADMAP.md: Promote 0.7.0 from upcoming to shipped under "Current state: 0.7.0"; point "next phase" at 0.8.0+.
|
|
33
|
+
15. ~ Rakefile: + -V mainfont=Charter -V monofont=Menlo on pandoc invocation in docs:md2pdf, for a cleaner serif body font and so code spans containing Unicode math glyphs (e.g. ∅) render correctly under xelatex.
|
|
34
|
+
16. ~ Namo::VERSION: /0.6.0/0.7.0/
|
|
35
|
+
|
|
4
36
|
20260511
|
|
5
37
|
0.6.0: + equality, pattern-match, and subset/superset operators
|
|
6
38
|
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Named dimensional data for Ruby.
|
|
|
4
4
|
|
|
5
5
|
Namo is a Ruby library for working with multi-dimensional data using named dimensions. It infers dimensions and coordinates from plain arrays of hashes — the same shape you get from databases, CSV files, JSON, and YAML — so there's no reshaping step.
|
|
6
6
|
|
|
7
|
-
The design rests on a few stances: every hash key is a dimension and none is privileged; formulae attach to a Namo alongside
|
|
7
|
+
The design rests on a few stances: every hash key is a dimension and none is privileged as a coordinate or value; formulae attach to a Namo alongside data and re-evaluate on each access, appearing as derived dimensions alongside the data dimensions; operators that combine Namos all take Namos and return Namos, so analytical pipelines close; and the formula mechanism is type-agnostic — strings, dates, booleans, and arbitrary Ruby objects work as readily as numbers.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -79,8 +79,41 @@ sales[quarter: ['Q1']]
|
|
|
79
79
|
# {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
|
|
80
80
|
# {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40}
|
|
81
81
|
# ]>
|
|
82
|
+
|
|
83
|
+
# Proc predicate
|
|
84
|
+
sales[price: ->(v){v < 20.0}]
|
|
85
|
+
# => #<Namo [
|
|
86
|
+
# {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
|
|
87
|
+
# {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
|
|
88
|
+
# ]>
|
|
89
|
+
|
|
90
|
+
# Regex predicate
|
|
91
|
+
sales[product: /^W/]
|
|
92
|
+
# => #<Namo [
|
|
93
|
+
# {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
|
|
94
|
+
# {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
|
|
95
|
+
# ]>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Procs receive the dimension value and select the row when they return truthy. They handle arbitrary predicates — multi-condition tests, nil-aware checks, anything Ruby can express — and compose with everything else:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
sales[price: ->(v){v < 20.0}, quantity: ->(v){v > 100}]
|
|
102
|
+
# => #<Namo [
|
|
103
|
+
# {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
|
|
104
|
+
# ]>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Regexes match against the dimension value coerced with `to_s`, so they work against strings, symbols, numbers, dates, or anything else with a sensible string form. `nil` becomes `""` — `//` matches it, `/./` doesn't.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
sales[product: /widget/i] # case-insensitive
|
|
111
|
+
sales[product: /Widget|Gadget/] # alternation
|
|
112
|
+
sales[product: /^W/, quarter: 'Q1'] # mixed with exact
|
|
82
113
|
```
|
|
83
114
|
|
|
115
|
+
Procs and regexes mix freely with exact values, arrays, ranges, projection, and contraction in the same `[]` call.
|
|
116
|
+
|
|
84
117
|
### Projection
|
|
85
118
|
|
|
86
119
|
Project to specific dimensions:
|
|
@@ -383,7 +416,7 @@ sales[:product, :quarter, :revenue]
|
|
|
383
416
|
# ]>
|
|
384
417
|
```
|
|
385
418
|
|
|
386
|
-
Formulae aren't materialised into
|
|
419
|
+
Formulae aren't materialised into row data — they re-evaluate on every access. A `:revenue` value reflects the current `:price` and `:quantity` at the moment you ask for it, so derived values stay in sync with whatever the underlying data is doing.
|
|
387
420
|
|
|
388
421
|
Formulae compose:
|
|
389
422
|
|
|
@@ -412,6 +445,78 @@ sales[product: 'Widget'][:revenue, :quarter]
|
|
|
412
445
|
|
|
413
446
|
Formulae carry through selection — a filtered Namo instance remembers its formulae.
|
|
414
447
|
|
|
448
|
+
### Coordinates and values
|
|
449
|
+
|
|
450
|
+
`dimensions` covers the *queryable namespace* — every name you can ask for, whether it lives in the row data or is computed by a formula. Once formulae are defined, they appear alongside data dimensions:
|
|
451
|
+
|
|
452
|
+
```ruby
|
|
453
|
+
sales[:revenue] = proc{|row| row[:price] * row[:quantity]}
|
|
454
|
+
|
|
455
|
+
sales.dimensions
|
|
456
|
+
# => [:product, :quarter, :price, :quantity, :revenue]
|
|
457
|
+
|
|
458
|
+
sales.data_dimensions
|
|
459
|
+
# => [:product, :quarter, :price, :quantity]
|
|
460
|
+
|
|
461
|
+
sales.derived_dimensions
|
|
462
|
+
# => [:revenue]
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
`coordinates` gives the unique values per dimension, including derived ones:
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
sales.coordinates[:product]
|
|
469
|
+
# => ['Widget', 'Gadget']
|
|
470
|
+
|
|
471
|
+
sales.coordinates[:revenue]
|
|
472
|
+
# => [1000.0, 1500.0]
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
`values` gives the full per-row sequence — duplicates preserved, row order preserved:
|
|
476
|
+
|
|
477
|
+
```ruby
|
|
478
|
+
sales.values[:product]
|
|
479
|
+
# => ['Widget', 'Widget', 'Gadget', 'Gadget']
|
|
480
|
+
|
|
481
|
+
sales.values[:revenue]
|
|
482
|
+
# => [1000.0, 1500.0, 1000.0, 1500.0]
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Both `coordinates` and `values` accept positional arguments. With no args they return a Hash across the queryable namespace; with one arg they lazily compute and return just that column as an Array; with multiple args they return a subset Hash containing just the requested columns:
|
|
486
|
+
|
|
487
|
+
```ruby
|
|
488
|
+
sales.values(:product)
|
|
489
|
+
# => ['Widget', 'Widget', 'Gadget', 'Gadget']
|
|
490
|
+
|
|
491
|
+
sales.values(:product, :quarter)
|
|
492
|
+
# => {
|
|
493
|
+
# product: ['Widget', 'Widget', 'Gadget', 'Gadget'],
|
|
494
|
+
# quarter: ['Q1', 'Q2', 'Q1', 'Q2']
|
|
495
|
+
# }
|
|
496
|
+
|
|
497
|
+
sales.coordinates(:revenue)
|
|
498
|
+
# => [1000.0, 1500.0]
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Single-arg access is lazy: `sales.values(:revenue)` evaluates the formula only across the rows of `:revenue`, without materialising the other columns. The bracket form (`sales.values[:revenue]`) still works through ordinary Hash lookup but pays for the full materialisation up front.
|
|
502
|
+
|
|
503
|
+
`coordinates` is `values` with `.uniq` applied per column — `coordinates(dim) == values(dim).uniq` holds for every dimension.
|
|
504
|
+
|
|
505
|
+
`to_h` is the Ruby-conventional alias for the full `values` Hash:
|
|
506
|
+
|
|
507
|
+
```ruby
|
|
508
|
+
sales.to_h
|
|
509
|
+
# => {
|
|
510
|
+
# product: ['Widget', 'Widget', 'Gadget', 'Gadget'],
|
|
511
|
+
# quarter: ['Q1', 'Q2', 'Q1', 'Q2'],
|
|
512
|
+
# price: [10.0, 10.0, 25.0, 25.0],
|
|
513
|
+
# quantity: [100, 150, 40, 60],
|
|
514
|
+
# revenue: [1000.0, 1500.0, 1000.0, 1500.0]
|
|
515
|
+
# }
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Unknown dimensions propagate `nil` per row — `values(:missing)` returns `[nil, nil, ...]` rather than raising or returning a sentinel, matching the convention used by `Row#[]` and `[]` selection. Use `dimensions.include?(:dim)` if you need to check membership directly.
|
|
519
|
+
|
|
415
520
|
### Enumerable
|
|
416
521
|
|
|
417
522
|
Namo includes `Enumerable`, so `each`, `reduce`, `map`, `select`, `min_by`, and all the rest work out of the box. Rows are yielded as `Row` objects, so formulae are accessible during enumeration:
|
|
@@ -443,7 +548,7 @@ sales.flat_map{|row| [row[:price]]}
|
|
|
443
548
|
|
|
444
549
|
### Extracting data
|
|
445
550
|
|
|
446
|
-
`to_a` returns an array of hashes:
|
|
551
|
+
`to_a` returns an array of hashes — the row-oriented form:
|
|
447
552
|
|
|
448
553
|
```ruby
|
|
449
554
|
sales[:product, :quarter, :revenue].to_a
|
|
@@ -455,6 +560,17 @@ sales[:product, :quarter, :revenue].to_a
|
|
|
455
560
|
# ]
|
|
456
561
|
```
|
|
457
562
|
|
|
563
|
+
`to_h` returns a hash of arrays — the columnar form (see [Coordinates and values](#coordinates-and-values) above):
|
|
564
|
+
|
|
565
|
+
```ruby
|
|
566
|
+
sales[:product, :quarter, :revenue].to_h
|
|
567
|
+
# => {
|
|
568
|
+
# product: ['Widget', 'Widget', 'Gadget', 'Gadget'],
|
|
569
|
+
# quarter: ['Q1', 'Q2', 'Q1', 'Q2'],
|
|
570
|
+
# revenue: [1000.0, 1500.0, 1000.0, 1500.0]
|
|
571
|
+
# }
|
|
572
|
+
```
|
|
573
|
+
|
|
458
574
|
## Why?
|
|
459
575
|
|
|
460
576
|
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 -o #{pdf}"
|
|
24
|
+
sh "pandoc #{f} --pdf-engine=xelatex -V geometry:margin=1in -V mainfont=Charter -V monofont=Menlo -o #{pdf}"
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
data/lib/Namo/Row.rb
CHANGED
data/lib/Namo/VERSION.rb
CHANGED
data/lib/namo.rb
CHANGED
|
@@ -12,15 +12,39 @@ class Namo
|
|
|
12
12
|
attr_accessor :formulae
|
|
13
13
|
|
|
14
14
|
def dimensions
|
|
15
|
-
@
|
|
15
|
+
@data.first.keys + @formulae.keys
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def
|
|
19
|
-
@
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
def data_dimensions
|
|
19
|
+
@data.first.keys
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def derived_dimensions
|
|
23
|
+
@formulae.keys
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def values(*dims)
|
|
27
|
+
if dims.empty?
|
|
28
|
+
dimensions.each_with_object({}){|dim, hash| hash[dim] = values_for(dim)}
|
|
29
|
+
elsif dims.length == 1
|
|
30
|
+
values_for(dims.first)
|
|
31
|
+
else
|
|
32
|
+
dims.each_with_object({}){|dim, hash| hash[dim] = values_for(dim)}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def coordinates(*dims)
|
|
37
|
+
if dims.empty?
|
|
38
|
+
values.transform_values(&:uniq)
|
|
39
|
+
elsif dims.length == 1
|
|
40
|
+
values(dims.first).uniq
|
|
41
|
+
else
|
|
42
|
+
dims.each_with_object({}){|dim, hash| hash[dim] = values(dim).uniq}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
values
|
|
24
48
|
end
|
|
25
49
|
|
|
26
50
|
def [](*names, **selections)
|
|
@@ -32,7 +56,7 @@ class Namo
|
|
|
32
56
|
projected = (
|
|
33
57
|
if negated.any?
|
|
34
58
|
excluded = negated.map(&:name)
|
|
35
|
-
kept =
|
|
59
|
+
kept = data_dimensions - excluded
|
|
36
60
|
rows.map do |row|
|
|
37
61
|
kept.each_with_object({}){|name, hash| hash[name] = row[name]}
|
|
38
62
|
end
|
|
@@ -58,31 +82,31 @@ class Namo
|
|
|
58
82
|
|
|
59
83
|
def +(other)
|
|
60
84
|
raise_unless_namo(other)
|
|
61
|
-
|
|
85
|
+
raise_unless_matching_data_dimensions(other)
|
|
62
86
|
self.class.new(@data + other.data, formulae: other.formulae.merge(@formulae))
|
|
63
87
|
end
|
|
64
88
|
|
|
65
89
|
def -(other)
|
|
66
90
|
raise_unless_namo(other)
|
|
67
|
-
|
|
91
|
+
raise_unless_matching_data_dimensions(other)
|
|
68
92
|
self.class.new(@data - other.data, formulae: @formulae.dup)
|
|
69
93
|
end
|
|
70
94
|
|
|
71
95
|
def &(other)
|
|
72
96
|
raise_unless_namo(other)
|
|
73
|
-
|
|
97
|
+
raise_unless_matching_data_dimensions(other)
|
|
74
98
|
self.class.new(@data & other.data, formulae: @formulae.dup)
|
|
75
99
|
end
|
|
76
100
|
|
|
77
101
|
def |(other)
|
|
78
102
|
raise_unless_namo(other)
|
|
79
|
-
|
|
103
|
+
raise_unless_matching_data_dimensions(other)
|
|
80
104
|
self.class.new((@data | other.data), formulae: other.formulae.merge(@formulae))
|
|
81
105
|
end
|
|
82
106
|
|
|
83
107
|
def ^(other)
|
|
84
108
|
raise_unless_namo(other)
|
|
85
|
-
|
|
109
|
+
raise_unless_matching_data_dimensions(other)
|
|
86
110
|
self.class.new((@data - other.data) + (other.data - @data), formulae: other.formulae.merge(@formulae))
|
|
87
111
|
end
|
|
88
112
|
|
|
@@ -109,25 +133,25 @@ class Namo
|
|
|
109
133
|
|
|
110
134
|
def <(other)
|
|
111
135
|
raise_unless_namo(other)
|
|
112
|
-
|
|
136
|
+
raise_unless_matching_data_dimensions(other)
|
|
113
137
|
proper_subset_of_rows?(other)
|
|
114
138
|
end
|
|
115
139
|
|
|
116
140
|
def <=(other)
|
|
117
141
|
raise_unless_namo(other)
|
|
118
|
-
|
|
142
|
+
raise_unless_matching_data_dimensions(other)
|
|
119
143
|
subset_of_rows?(other)
|
|
120
144
|
end
|
|
121
145
|
|
|
122
146
|
def >(other)
|
|
123
147
|
raise_unless_namo(other)
|
|
124
|
-
|
|
148
|
+
raise_unless_matching_data_dimensions(other)
|
|
125
149
|
other.proper_subset_of_rows?(self)
|
|
126
150
|
end
|
|
127
151
|
|
|
128
152
|
def >=(other)
|
|
129
153
|
raise_unless_namo(other)
|
|
130
|
-
|
|
154
|
+
raise_unless_matching_data_dimensions(other)
|
|
131
155
|
other.subset_of_rows?(self)
|
|
132
156
|
end
|
|
133
157
|
|
|
@@ -142,7 +166,7 @@ class Namo
|
|
|
142
166
|
protected
|
|
143
167
|
|
|
144
168
|
def canonical_data
|
|
145
|
-
@data.sort_by{|row| row.values_at(*
|
|
169
|
+
@data.sort_by{|row| row.values_at(*data_dimensions.sort)}
|
|
146
170
|
end
|
|
147
171
|
|
|
148
172
|
def subset_of_rows?(other)
|
|
@@ -157,15 +181,23 @@ class Namo
|
|
|
157
181
|
|
|
158
182
|
private
|
|
159
183
|
|
|
184
|
+
def values_for(dim)
|
|
185
|
+
if data_dimensions.include?(dim)
|
|
186
|
+
@data.map{|row_data| row_data[dim]}
|
|
187
|
+
else
|
|
188
|
+
@data.map{|row_data| Row.new(row_data, @formulae)[dim]}
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
160
192
|
def raise_unless_namo(other)
|
|
161
193
|
unless other.is_a?(Namo)
|
|
162
194
|
raise TypeError, "can't compare Namo with #{other.class}"
|
|
163
195
|
end
|
|
164
196
|
end
|
|
165
197
|
|
|
166
|
-
def
|
|
167
|
-
unless
|
|
168
|
-
raise ArgumentError, "dimensions don't match: #{
|
|
198
|
+
def raise_unless_matching_data_dimensions(other)
|
|
199
|
+
unless data_dimensions == other.data_dimensions
|
|
200
|
+
raise ArgumentError, "dimensions don't match: #{data_dimensions} vs #{other.data_dimensions}"
|
|
169
201
|
end
|
|
170
202
|
end
|
|
171
203
|
|
data/test/Namo/Row_test.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require 'date'
|
|
1
2
|
require 'minitest/autorun'
|
|
2
3
|
require 'minitest-spec-context'
|
|
3
4
|
|
|
@@ -59,6 +60,154 @@ describe Namo::Row do
|
|
|
59
60
|
_(row.match?(product: 'Widget', quarter: 'Q1')).must_equal true
|
|
60
61
|
_(row.match?(product: 'Widget', quarter: 'Q2')).must_equal false
|
|
61
62
|
end
|
|
63
|
+
|
|
64
|
+
describe "Proc predicates" do
|
|
65
|
+
it "matches when the proc returns true" do
|
|
66
|
+
_(row.match?(price: ->(v){v < 15.0})).must_equal true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "doesn't match when the proc returns false" do
|
|
70
|
+
_(row.match?(price: ->(v){v > 100.0})).must_equal false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "doesn't match when the proc returns nil" do
|
|
74
|
+
_(row.match?(price: ->(v){nil})).must_equal false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "matches when the proc returns a truthy non-boolean" do
|
|
78
|
+
_(row.match?(price: ->(v){"truthy"})).must_equal true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "passes nil to the proc when the dimension is missing" do
|
|
82
|
+
seen = nil
|
|
83
|
+
row.match?(missing: ->(v){seen = v; true})
|
|
84
|
+
_(seen).must_be_nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "lets the proc decide what to do with a nil value" do
|
|
88
|
+
_(row.match?(missing: ->(v){v.nil?})).must_equal true
|
|
89
|
+
_(row.match?(missing: ->(v){!v.nil?})).must_equal false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "composes with an exact value on another dimension" do
|
|
93
|
+
_(row.match?(price: ->(v){v < 15.0}, product: 'Widget')).must_equal true
|
|
94
|
+
_(row.match?(price: ->(v){v < 15.0}, product: 'Gadget')).must_equal false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "composes with an array on another dimension" do
|
|
98
|
+
_(row.match?(price: ->(v){v < 15.0}, product: ['Widget', 'Gadget'])).must_equal true
|
|
99
|
+
_(row.match?(price: ->(v){v < 15.0}, product: ['Gadget'])).must_equal false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "composes with a range on another dimension" do
|
|
103
|
+
_(row.match?(price: ->(v){v < 15.0}, quantity: 50..150)).must_equal true
|
|
104
|
+
_(row.match?(price: ->(v){v < 15.0}, quantity: 200..300)).must_equal false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "composes with a regex on another dimension" do
|
|
108
|
+
_(row.match?(price: ->(v){v < 15.0}, product: /^W/)).must_equal true
|
|
109
|
+
_(row.match?(price: ->(v){v < 15.0}, product: /^G/)).must_equal false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "composes multiple proc predicates across dimensions" do
|
|
113
|
+
_(row.match?(
|
|
114
|
+
price: ->(v){v < 15.0},
|
|
115
|
+
quantity: ->(v){v >= 100}
|
|
116
|
+
)).must_equal true
|
|
117
|
+
_(row.match?(
|
|
118
|
+
price: ->(v){v < 15.0},
|
|
119
|
+
quantity: ->(v){v >= 200}
|
|
120
|
+
)).must_equal false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "carries through to a formula-defined dimension" do
|
|
124
|
+
formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
125
|
+
_(row.match?(revenue: ->(v){v == 1000.0})).must_equal true
|
|
126
|
+
_(row.match?(revenue: ->(v){v > 5000.0})).must_equal false
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
describe "Regexp predicates" do
|
|
131
|
+
it "matches against a String value" do
|
|
132
|
+
_(row.match?(product: /Widget/)).must_equal true
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "doesn't match when the regex doesn't apply" do
|
|
136
|
+
_(row.match?(product: /Gadget/)).must_equal false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "supports case-insensitive matching" do
|
|
140
|
+
_(row.match?(product: /widget/i)).must_equal true
|
|
141
|
+
_(row.match?(product: /widget/)).must_equal false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "supports anchored patterns" do
|
|
145
|
+
_(row.match?(product: /^Wid/)).must_equal true
|
|
146
|
+
_(row.match?(product: /^Gad/)).must_equal false
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "coerces Integer values via to_s" do
|
|
150
|
+
_(row.match?(quantity: /100/)).must_equal true
|
|
151
|
+
_(row.match?(quantity: /^1/)).must_equal true
|
|
152
|
+
_(row.match?(quantity: /^9/)).must_equal false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "coerces Float values via to_s" do
|
|
156
|
+
_(row.match?(price: /^10\./)).must_equal true
|
|
157
|
+
_(row.match?(price: /\.0$/)).must_equal true
|
|
158
|
+
_(row.match?(price: /^99/)).must_equal false
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "coerces Date values via to_s" do
|
|
162
|
+
row_data[:date] = Date.new(2026, 5, 21)
|
|
163
|
+
_(row.match?(date: /^2026/)).must_equal true
|
|
164
|
+
_(row.match?(date: /-05-/)).must_equal true
|
|
165
|
+
_(row.match?(date: /^2025/)).must_equal false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "coerces Symbol values via to_s" do
|
|
169
|
+
row_data[:tag] = :priority
|
|
170
|
+
_(row.match?(tag: /priority/)).must_equal true
|
|
171
|
+
_(row.match?(tag: /^pri/)).must_equal true
|
|
172
|
+
_(row.match?(tag: /xyz/)).must_equal false
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "coerces nil to an empty string" do
|
|
176
|
+
_(row.match?(missing: //)).must_equal true
|
|
177
|
+
_(row.match?(missing: /./)).must_equal false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it "composes with an exact value on another dimension" do
|
|
181
|
+
_(row.match?(product: /^W/, quarter: 'Q1')).must_equal true
|
|
182
|
+
_(row.match?(product: /^W/, quarter: 'Q2')).must_equal false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "composes with an array on another dimension" do
|
|
186
|
+
_(row.match?(product: /^W/, quarter: ['Q1', 'Q2'])).must_equal true
|
|
187
|
+
_(row.match?(product: /^W/, quarter: ['Q3'])).must_equal false
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "composes with a range on another dimension" do
|
|
191
|
+
_(row.match?(product: /^W/, price: 5.0..15.0)).must_equal true
|
|
192
|
+
_(row.match?(product: /^W/, price: 20.0..30.0)).must_equal false
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "composes with a proc on another dimension" do
|
|
196
|
+
_(row.match?(product: /^W/, quantity: ->(v){v >= 100})).must_equal true
|
|
197
|
+
_(row.match?(product: /^W/, quantity: ->(v){v >= 200})).must_equal false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it "composes multiple regex predicates across dimensions" do
|
|
201
|
+
_(row.match?(product: /^W/, quarter: /^Q/)).must_equal true
|
|
202
|
+
_(row.match?(product: /^W/, quarter: /^X/)).must_equal false
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it "carries through to a formula-defined dimension" do
|
|
206
|
+
formulae[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
|
|
207
|
+
_(row.match?(label: /Widget-Q1/)).must_equal true
|
|
208
|
+
_(row.match?(label: /Gadget/)).must_equal false
|
|
209
|
+
end
|
|
210
|
+
end
|
|
62
211
|
end
|
|
63
212
|
|
|
64
213
|
describe "#to_h" do
|
data/test/namo_test.rb
CHANGED
|
@@ -21,19 +21,159 @@ describe Namo do
|
|
|
21
21
|
it "infers dimensions from hash keys" do
|
|
22
22
|
_(sales.dimensions).must_equal [:product, :quarter, :price, :quantity]
|
|
23
23
|
end
|
|
24
|
+
|
|
25
|
+
it "includes derived dimensions after storage dimensions" do
|
|
26
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
27
|
+
sales[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
|
|
28
|
+
_(sales.dimensions).must_equal [:product, :quarter, :price, :quantity, :revenue, :label]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "reflects mutation on the next call" do
|
|
32
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
33
|
+
_(sales.dimensions).must_include :revenue
|
|
34
|
+
sales.formulae.delete(:revenue)
|
|
35
|
+
_(sales.dimensions).wont_include :revenue
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe "#data_dimensions" do
|
|
40
|
+
it "returns only the storage keys" do
|
|
41
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
42
|
+
_(sales.data_dimensions).must_equal [:product, :quarter, :price, :quantity]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe "#derived_dimensions" do
|
|
47
|
+
it "returns only the formula keys" do
|
|
48
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
49
|
+
_(sales.derived_dimensions).must_equal [:revenue]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "is empty when no formulae are defined" do
|
|
53
|
+
_(sales.derived_dimensions).must_equal []
|
|
54
|
+
end
|
|
24
55
|
end
|
|
25
56
|
|
|
26
57
|
describe "#coordinates" do
|
|
27
|
-
it "
|
|
58
|
+
it "with no args returns a Hash of unique values for each dimension" do
|
|
28
59
|
_(sales.coordinates).must_equal ({
|
|
29
60
|
product: ['Widget', 'Gadget'],
|
|
30
61
|
quarter: ['Q1', 'Q2'],
|
|
31
62
|
price: [10.0, 25.0],
|
|
32
63
|
quantity: [100, 150, 40, 60]
|
|
33
64
|
})
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "0.6.0-style indexing still works" do
|
|
34
68
|
_(sales.coordinates[:product]).must_equal ['Widget', 'Gadget']
|
|
35
69
|
_(sales.coordinates[:quarter]).must_equal ['Q1', 'Q2']
|
|
36
70
|
end
|
|
71
|
+
|
|
72
|
+
it "with one arg returns just that column's unique values as an Array" do
|
|
73
|
+
_(sales.coordinates(:product)).must_equal ['Widget', 'Gadget']
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "with one arg returns [nil] for an unknown dimension (nil values uniqued)" do
|
|
77
|
+
_(sales.coordinates(:missing)).must_equal [nil]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "with one arg evaluates a derived dimension" do
|
|
81
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
82
|
+
_(sales.coordinates(:revenue)).must_equal [1000.0, 1500.0]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "with multiple args returns a subset Hash" do
|
|
86
|
+
_(sales.coordinates(:product, :quarter)).must_equal({
|
|
87
|
+
product: ['Widget', 'Gadget'],
|
|
88
|
+
quarter: ['Q1', 'Q2']
|
|
89
|
+
})
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "with multiple args includes unknown dimensions as [nil]" do
|
|
93
|
+
_(sales.coordinates(:product, :missing)).must_equal({
|
|
94
|
+
product: ['Widget', 'Gadget'],
|
|
95
|
+
missing: [nil]
|
|
96
|
+
})
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "covers derived dimensions in the no-arg form" do
|
|
100
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
101
|
+
_(sales.coordinates[:revenue]).must_equal [1000.0, 1500.0]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
describe "#values" do
|
|
106
|
+
it "with no args returns a Hash of full sequences for each dimension" do
|
|
107
|
+
_(sales.values).must_equal({
|
|
108
|
+
product: ['Widget', 'Widget', 'Gadget', 'Gadget'],
|
|
109
|
+
quarter: ['Q1', 'Q2', 'Q1', 'Q2'],
|
|
110
|
+
price: [10.0, 10.0, 25.0, 25.0],
|
|
111
|
+
quantity: [100, 150, 40, 60]
|
|
112
|
+
})
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "with one arg returns just that column as an Array, preserving duplicates and order" do
|
|
116
|
+
_(sales.values(:product)).must_equal ['Widget', 'Widget', 'Gadget', 'Gadget']
|
|
117
|
+
_(sales.values(:price)).must_equal [10.0, 10.0, 25.0, 25.0]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "with one arg returns an Array of nils for an unknown dimension (one nil per row)" do
|
|
121
|
+
_(sales.values(:missing)).must_equal [nil, nil, nil, nil]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "with one arg evaluates a derived dimension across all rows" do
|
|
125
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
126
|
+
_(sales.values(:revenue)).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "with multiple args returns a subset Hash" do
|
|
130
|
+
_(sales.values(:product, :quarter)).must_equal({
|
|
131
|
+
product: ['Widget', 'Widget', 'Gadget', 'Gadget'],
|
|
132
|
+
quarter: ['Q1', 'Q2', 'Q1', 'Q2']
|
|
133
|
+
})
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "with multiple args includes unknown dimensions as Arrays of nils" do
|
|
137
|
+
_(sales.values(:product, :missing)).must_equal({
|
|
138
|
+
product: ['Widget', 'Widget', 'Gadget', 'Gadget'],
|
|
139
|
+
missing: [nil, nil, nil, nil]
|
|
140
|
+
})
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "covers derived dimensions in the no-arg form" do
|
|
144
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
145
|
+
_(sales.values[:revenue]).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
describe "#to_h" do
|
|
150
|
+
it "returns the full values Hash" do
|
|
151
|
+
_(sales.to_h).must_equal sales.values
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
describe "aspect consistency" do
|
|
156
|
+
it "satisfies coordinates(dim) == values(dim).uniq for each dimension" do
|
|
157
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
158
|
+
sales.dimensions.each do |dim|
|
|
159
|
+
_(sales.coordinates(dim)).must_equal sales.values(dim).uniq
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
describe "live-view semantics" do
|
|
165
|
+
it "reflects added rows on next call" do
|
|
166
|
+
_(sales.values(:product)).must_equal ['Widget', 'Widget', 'Gadget', 'Gadget']
|
|
167
|
+
sales.data << {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
|
|
168
|
+
_(sales.values(:product)).must_equal ['Widget', 'Widget', 'Gadget', 'Gadget', 'Thingo']
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it "reflects added formulae on next call" do
|
|
172
|
+
_(sales.derived_dimensions).must_equal []
|
|
173
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
174
|
+
_(sales.derived_dimensions).must_equal [:revenue]
|
|
175
|
+
_(sales.coordinates(:revenue)).must_equal [1000.0, 1500.0]
|
|
176
|
+
end
|
|
37
177
|
end
|
|
38
178
|
|
|
39
179
|
describe "#[]" do
|
|
@@ -132,6 +272,110 @@ describe Namo do
|
|
|
132
272
|
]
|
|
133
273
|
end
|
|
134
274
|
end
|
|
275
|
+
|
|
276
|
+
context "proc selection" do
|
|
277
|
+
it "selects rows where the proc returns truthy" do
|
|
278
|
+
result = sales[price: ->(v){v < 15.0}]
|
|
279
|
+
_(result.to_a.count).must_equal 2
|
|
280
|
+
_(result.to_a.map{|row| row[:product]}).must_equal ['Widget', 'Widget']
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
it "selects on multiple proc dimensions" do
|
|
284
|
+
result = sales[price: ->(v){v < 30.0}, quantity: ->(v){v > 50}]
|
|
285
|
+
_(result.to_a).must_equal [
|
|
286
|
+
{product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
|
|
287
|
+
{product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
|
|
288
|
+
{product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
|
|
289
|
+
]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
it "composes with projection in a single call" do
|
|
293
|
+
result = sales[:product, :price, price: ->(v){v < 15.0}]
|
|
294
|
+
_(result.to_a).must_equal [
|
|
295
|
+
{product: 'Widget', price: 10.0},
|
|
296
|
+
{product: 'Widget', price: 10.0}
|
|
297
|
+
]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
it "composes with contraction in a single call" do
|
|
301
|
+
result = sales[-:quantity, price: ->(v){v < 15.0}]
|
|
302
|
+
_(result.to_a).must_equal [
|
|
303
|
+
{product: 'Widget', quarter: 'Q1', price: 10.0},
|
|
304
|
+
{product: 'Widget', quarter: 'Q2', price: 10.0}
|
|
305
|
+
]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it "selects on a formula-defined dimension" do
|
|
309
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
310
|
+
result = sales[revenue: ->(v){v >= 1500.0}]
|
|
311
|
+
_(result.to_a).must_equal [
|
|
312
|
+
{product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
|
|
313
|
+
{product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
|
|
314
|
+
]
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
context "regex selection" do
|
|
319
|
+
it "selects by regex against String values" do
|
|
320
|
+
result = sales[product: /^W/]
|
|
321
|
+
_(result.to_a.count).must_equal 2
|
|
322
|
+
_(result.to_a.map{|row| row[:product]}).must_equal ['Widget', 'Widget']
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
it "supports case-insensitive matching" do
|
|
326
|
+
result = sales[product: /widget/i]
|
|
327
|
+
_(result.to_a.count).must_equal 2
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it "supports alternation" do
|
|
331
|
+
result = sales[product: /Widget|Gadget/]
|
|
332
|
+
_(result.to_a.count).must_equal 4
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
it "coerces non-String values via to_s" do
|
|
336
|
+
result = sales[quantity: /^1/]
|
|
337
|
+
_(result.to_a.map{|row| row[:quantity]}).must_equal [100, 150]
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
it "composes with an exact value on another dimension" do
|
|
341
|
+
result = sales[product: /^W/, quarter: 'Q1']
|
|
342
|
+
_(result.to_a).must_equal [
|
|
343
|
+
{product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100}
|
|
344
|
+
]
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it "composes with projection in a single call" do
|
|
348
|
+
result = sales[:product, :quarter, product: /^W/]
|
|
349
|
+
_(result.to_a).must_equal [
|
|
350
|
+
{product: 'Widget', quarter: 'Q1'},
|
|
351
|
+
{product: 'Widget', quarter: 'Q2'}
|
|
352
|
+
]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it "composes with contraction in a single call" do
|
|
356
|
+
result = sales[-:price, -:quantity, product: /^W/]
|
|
357
|
+
_(result.to_a).must_equal [
|
|
358
|
+
{product: 'Widget', quarter: 'Q1'},
|
|
359
|
+
{product: 'Widget', quarter: 'Q2'}
|
|
360
|
+
]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
it "selects on a formula-defined dimension" do
|
|
364
|
+
sales[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
|
|
365
|
+
result = sales[label: /Widget/]
|
|
366
|
+
_(result.to_a.count).must_equal 2
|
|
367
|
+
_(result.map{|row| row[:label]}).must_equal ['Widget-Q1', 'Widget-Q2']
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
context "mixed proc and regex selection" do
|
|
372
|
+
it "combines a proc and a regex across dimensions" do
|
|
373
|
+
result = sales[product: /^W/, quantity: ->(v){v > 100}]
|
|
374
|
+
_(result.to_a).must_equal [
|
|
375
|
+
{product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
|
|
376
|
+
]
|
|
377
|
+
end
|
|
378
|
+
end
|
|
135
379
|
end
|
|
136
380
|
|
|
137
381
|
describe "#[]= formulae" do
|