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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d29843b2d9895ba401fa013ea83753f548458bc09bcd8e61218400f81950e33
4
- data.tar.gz: 6b8772e13cd773d41ae4cabddb019bddb533cbc9a24ece7729977543e462a30c
3
+ metadata.gz: 86f572755292e2bac9808f09adb3169cec06066a5687ec2ea6bd9354f97d592a
4
+ data.tar.gz: edafcc183a1a9e5fb1212bb66a8b6906b94bf8e37a7375d3f9d304e5632ddba3
5
5
  SHA512:
6
- metadata.gz: 22c4c03943617b1ec56e45ace4722019e6d54bb12f9b30fb3e2b85eebce132477a380ccde5529ea65782be03f060abd7507ebbfbe8b06d3c8ad10d7b90319061
7
- data.tar.gz: 17e0163a3353024bec9826d5fc95773893d8195dbb7efe87ff7e814da7c74ca1391f168ca10025a6deb9fcd44e7ecae8fc3a10c44f29f9ef1e2018a0de96aa97
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 stored data and re-evaluate on each access; the 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.
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 stored columns — 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.
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
@@ -16,6 +16,10 @@ class Namo
16
16
  case coordinate
17
17
  when Array, Range
18
18
  coordinate.include?(self[dimension])
19
+ when Proc
20
+ coordinate.call(self[dimension])
21
+ when Regexp
22
+ coordinate.match?(self[dimension].to_s)
19
23
  else
20
24
  self[dimension] == coordinate
21
25
  end
data/lib/Namo/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # Namo::VERSION
3
3
 
4
4
  class Namo
5
- VERSION = '0.6.0'
5
+ VERSION = '0.8.0'
6
6
  end
data/lib/namo.rb CHANGED
@@ -12,15 +12,39 @@ class Namo
12
12
  attr_accessor :formulae
13
13
 
14
14
  def dimensions
15
- @dimensions ||= @data.first.keys
15
+ @data.first.keys + @formulae.keys
16
16
  end
17
17
 
18
- def coordinates
19
- @coordinates ||= (
20
- dimensions.each_with_object({}) do |dimension, hash|
21
- hash[dimension] = @data.map{|row| row[dimension]}.uniq
22
- end
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 = dimensions - excluded
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
- raise_unless_matching_dimensions(other)
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
- raise_unless_matching_dimensions(other)
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
- raise_unless_matching_dimensions(other)
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
- raise_unless_matching_dimensions(other)
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
- raise_unless_matching_dimensions(other)
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
- raise_unless_matching_dimensions(other)
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
- raise_unless_matching_dimensions(other)
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
- raise_unless_matching_dimensions(other)
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
- raise_unless_matching_dimensions(other)
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(*dimensions.sort)}
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 raise_unless_matching_dimensions(other)
167
- unless dimensions == other.dimensions
168
- raise ArgumentError, "dimensions don't match: #{dimensions} vs #{other.dimensions}"
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
 
@@ -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 "extracts unique values for each dimension" do
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
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.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran