namo 0.3.0 → 0.5.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: a0cb29939a05de5108211f0d9f89b8c6b3cd0826433199f3f4014f1201a28fee
4
- data.tar.gz: d628a872b860561da5c9f02867123864508c34c3311e7d9962457146226d1071
3
+ metadata.gz: f1cf8cf203da06b319112fec69ad8fae3dddc73ef850d3f84de4d13df2c51249
4
+ data.tar.gz: 1e80df8ee9c7be401c3f9cbf9625eceef0a87b6136a9d8b3825379b32eecff3f
5
5
  SHA512:
6
- metadata.gz: 72b7db10583b8316b0e727b4af8404c2069533049c40e83a3974841b8a1822139898922492f721e31328632b32582c8795a50fb9a53262bee04fa5bc75672713
7
- data.tar.gz: b269559c27dd1999ba2c3fb638c85abc0ee22530d984ffe620b2ac9e3fa1726655ac7c7d26c96273baf1bd1396df6ff0eeaeaf7c4e71753aee588eabdb09417f
6
+ metadata.gz: 712227a916253708637a1ca8c1f177e1ab36daba694df13edc2918943779751ba89e71f8d0f578b44d285abf522a869d5f16c3b0f8377899fa7818bbd6009d41
7
+ data.tar.gz: 7c50400df852f31b3a6a12011486cd72f32c946b94c6f7e71a9d83358a50bda4eec9b8854442181112101d82b4b154e61c6159746c697d0adec192f33d99f5a3
data/CHANGELOG CHANGED
@@ -1,6 +1,25 @@
1
1
  CHANGELOG
2
2
  _________
3
3
 
4
+ 20260416
5
+ 0.5.0: + row-axis set operations: intersection (&), union (|), symmetric difference (^)
6
+
7
+ 1. + Namo#&: Intersection. Returns rows present in both Namo objects. Requires matching dimensions.
8
+ 2. + Namo#|: Union. Returns all rows from both sides, deduplicated. Requires matching dimensions.
9
+ 3. + Namo#^: Symmetric difference. Returns rows in one side but not both. Requires matching dimensions.
10
+ 4. ~ Namo_test.rb: Add tests for #&, #|, and #^.
11
+ 5. ~ README.md: + Intersection section, + Union section, + Symmetric difference section.
12
+ 6. ~ Namo::VERSION: /0.4.0/0.5.0/
13
+
14
+ 20260415
15
+ 0.4.0: + concatenation (+) and row removal (-)
16
+
17
+ 1. + Namo#+: Concatenate two Namo instances with matching dimensions. Appends rows from the second to the first. Raises ArgumentError when dimensions differ.
18
+ 2. + Namo#-: Remove rows from the first Namo that appear exactly in the second. Whole-row set difference. Raises ArgumentError when dimensions differ.
19
+ 3. ~ Namo_test.rb: Add tests for #+ and #-.
20
+ 4. ~ README.md: + Concatenation section, + Row removal section.
21
+ 5. ~ Namo::VERSION: /0.3.0/0.4.0/
22
+
4
23
  20260415
5
24
  0.3.0: + contraction
6
25
 
data/README.md CHANGED
@@ -153,6 +153,133 @@ sales[-:price, -:quantity, product: 'Widget']
153
153
 
154
154
  Selection, projection, and contraction always return a new Namo instance, so everything chains.
155
155
 
156
+ ### Concatenation
157
+
158
+ `+` combines two Namo objects that share the same dimensions by appending the rows of the second to the first:
159
+
160
+ ```ruby
161
+ q1_sales = Namo.new([
162
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
163
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40}
164
+ ])
165
+
166
+ q2_sales = Namo.new([
167
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
168
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
169
+ ])
170
+
171
+ all_sales = q1_sales + q2_sales
172
+ # => #<Namo [
173
+ # {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
174
+ # {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
175
+ # {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
176
+ # {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
177
+ # ]>
178
+ ```
179
+
180
+ The dimensions must match — concatenating Namo objects with different dimensions raises an `ArgumentError`. Formulae carry through from the left-hand side.
181
+
182
+ ### Row Removal
183
+
184
+ `-` removes from the first Namo any row that appears exactly in the second:
185
+
186
+ ```ruby
187
+ sales = Namo.new([
188
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
189
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
190
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
191
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
192
+ ])
193
+
194
+ discontinued = Namo.new([
195
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
196
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
197
+ ])
198
+
199
+ sales - discontinued
200
+ # => #<Namo [
201
+ # {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
202
+ # {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
203
+ # ]>
204
+ ```
205
+
206
+ Removal is exact — every dimension, every value must match. The dimensions must match; different dimensions raise an `ArgumentError`. Formulae carry through from the left-hand side.
207
+
208
+ ### Intersection
209
+
210
+ `&` returns the rows present in both Namo objects, like `Array#&`:
211
+
212
+ ```ruby
213
+ sales = Namo.new([
214
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
215
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
216
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
217
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
218
+ ])
219
+
220
+ confirmed = Namo.new([
221
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
222
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
223
+ ])
224
+
225
+ sales & confirmed
226
+ # => #<Namo [
227
+ # {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
228
+ # {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
229
+ # ]>
230
+ ```
231
+
232
+ The dimensions must match; different dimensions raise an `ArgumentError`. Formulae carry through from the left-hand side.
233
+
234
+ ### Union
235
+
236
+ `|` returns all rows from both sides, deduplicated, like `Array#|`:
237
+
238
+ ```ruby
239
+ q1_sales = Namo.new([
240
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
241
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40}
242
+ ])
243
+
244
+ all_sales = Namo.new([
245
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
246
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
247
+ ])
248
+
249
+ q1_sales | all_sales
250
+ # => #<Namo [
251
+ # {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
252
+ # {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
253
+ # {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
254
+ # ]>
255
+ ```
256
+
257
+ The dimensions must match; different dimensions raise an `ArgumentError`. Formulae merge from both sides; the left-hand side's formulae take precedence on conflict.
258
+
259
+ ### Symmetric Difference
260
+
261
+ `^` returns rows that appear in one side but not both:
262
+
263
+ ```ruby
264
+ set_a = Namo.new([
265
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
266
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40}
267
+ ])
268
+
269
+ set_b = Namo.new([
270
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
271
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
272
+ ])
273
+
274
+ set_a ^ set_b
275
+ # => #<Namo [
276
+ # {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
277
+ # {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
278
+ # ]>
279
+ ```
280
+
281
+ The dimensions must match; different dimensions raise an `ArgumentError`. Formulae merge from both sides; the left-hand side's formulae take precedence on conflict.
282
+
156
283
  ### Formulae
157
284
 
158
285
  Define computed dimensions using `[]=`:
data/lib/Namo/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # Namo::VERSION
3
3
 
4
4
  class Namo
5
- VERSION = '0.3.0'
5
+ VERSION = '0.5.0'
6
6
  end
data/lib/namo.rb CHANGED
@@ -56,6 +56,41 @@ class Namo
56
56
  @data.each{|row_data| block.call(Row.new(row_data, @formulae))}
57
57
  end
58
58
 
59
+ def +(other)
60
+ unless dimensions == other.dimensions
61
+ raise ArgumentError, "dimensions do not match"
62
+ end
63
+ self.class.new(@data + other.data, formulae: other.formulae.merge(@formulae))
64
+ end
65
+
66
+ def -(other)
67
+ unless dimensions == other.dimensions
68
+ raise ArgumentError, "dimensions do not match"
69
+ end
70
+ self.class.new(@data - other.data, formulae: @formulae.dup)
71
+ end
72
+
73
+ def &(other)
74
+ unless dimensions == other.dimensions
75
+ raise ArgumentError, "dimensions do not match"
76
+ end
77
+ self.class.new(@data & other.data, formulae: @formulae.dup)
78
+ end
79
+
80
+ def |(other)
81
+ unless dimensions == other.dimensions
82
+ raise ArgumentError, "dimensions do not match"
83
+ end
84
+ self.class.new((@data | other.data), formulae: other.formulae.merge(@formulae))
85
+ end
86
+
87
+ def ^(other)
88
+ unless dimensions == other.dimensions
89
+ raise ArgumentError, "dimensions do not match"
90
+ end
91
+ self.class.new((@data - other.data) + (other.data - @data), formulae: other.formulae.merge(@formulae))
92
+ end
93
+
59
94
  def to_a
60
95
  @data.map do |row|
61
96
  row.keys.each_with_object({}) do |key, hash|
data/test/Namo_test.rb CHANGED
@@ -251,6 +251,221 @@ describe Namo do
251
251
  end
252
252
  end
253
253
 
254
+ describe "#+" do
255
+ let(:more_data) do
256
+ [
257
+ {product: 'Widget', quarter: 'Q3', price: 10.0, quantity: 200},
258
+ {product: 'Gadget', quarter: 'Q3', price: 25.0, quantity: 80}
259
+ ]
260
+ end
261
+
262
+ let(:more_sales) do
263
+ Namo.new(more_data)
264
+ end
265
+
266
+ it "concatenates rows" do
267
+ result = sales + more_sales
268
+ _(result.to_a.count).must_equal 6
269
+ _(result.to_a).must_equal(sample_data + more_data)
270
+ end
271
+
272
+ it "preserves dimensions" do
273
+ result = sales + more_sales
274
+ _(result.dimensions).must_equal [:product, :quarter, :price, :quantity]
275
+ end
276
+
277
+ it "carries formulae through from self" do
278
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
279
+ result = sales + more_sales
280
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0, 1000.0, 1500.0, 2000.0, 2000.0]
281
+ end
282
+
283
+ it "merges formulae from other" do
284
+ more_sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
285
+ result = sales + more_sales
286
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0, 1000.0, 1500.0, 2000.0, 2000.0]
287
+ end
288
+
289
+ it "prefers self's formulae on conflict" do
290
+ sales[:label] = proc{|r| "self: #{r[:product]}"}
291
+ more_sales[:label] = proc{|r| "other: #{r[:product]}"}
292
+ result = sales + more_sales
293
+ _(result.map{|row| row[:label]}).must_equal [
294
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget',
295
+ 'self: Widget', 'self: Gadget'
296
+ ]
297
+ end
298
+
299
+ it "raises when dimensions differ" do
300
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
301
+ _ { sales + other }.must_raise ArgumentError
302
+ end
303
+ end
304
+
305
+ describe "#-" do
306
+ let(:to_remove) do
307
+ Namo.new([
308
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
309
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
310
+ ])
311
+ end
312
+
313
+ it "removes matching rows" do
314
+ result = sales - to_remove
315
+ _(result.to_a.count).must_equal 2
316
+ _(result.to_a).must_equal [
317
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
318
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40}
319
+ ]
320
+ end
321
+
322
+ it "preserves non-matching rows" do
323
+ other = Namo.new([{product: 'Thingo', quarter: 'Q4', price: 99.0, quantity: 1}])
324
+ result = sales - other
325
+ _(result.to_a).must_equal sample_data
326
+ end
327
+
328
+ it "carries formulae through from self" do
329
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
330
+ result = sales - to_remove
331
+ _(result.map{|row| row[:revenue]}).must_equal [1500.0, 1000.0]
332
+ end
333
+
334
+ it "raises when dimensions differ" do
335
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
336
+ _ { sales - other }.must_raise ArgumentError
337
+ end
338
+ end
339
+
340
+ describe "#&" do
341
+ let(:other) do
342
+ Namo.new([
343
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
344
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60},
345
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
346
+ ])
347
+ end
348
+
349
+ it "returns rows present in both" do
350
+ result = sales & other
351
+ _(result.to_a).must_equal [
352
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
353
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
354
+ ]
355
+ end
356
+
357
+ it "returns empty when nothing overlaps" do
358
+ other = Namo.new([{product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}])
359
+ result = sales & other
360
+ _(result.to_a).must_equal []
361
+ end
362
+
363
+ it "preserves dimensions" do
364
+ result = sales & other
365
+ _(result.dimensions).must_equal [:product, :quarter, :price, :quantity]
366
+ end
367
+
368
+ it "carries formulae through from self" do
369
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
370
+ result = sales & other
371
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0]
372
+ end
373
+
374
+ it "raises when dimensions differ" do
375
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
376
+ _ { sales & other }.must_raise ArgumentError
377
+ end
378
+ end
379
+
380
+ describe "#|" do
381
+ let(:other) do
382
+ Namo.new([
383
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
384
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
385
+ ])
386
+ end
387
+
388
+ it "returns all rows deduplicated" do
389
+ result = sales | other
390
+ _(result.to_a).must_equal [
391
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
392
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
393
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
394
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60},
395
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
396
+ ]
397
+ end
398
+
399
+ it "preserves dimensions" do
400
+ result = sales | other
401
+ _(result.dimensions).must_equal [:product, :quarter, :price, :quantity]
402
+ end
403
+
404
+ it "merges formulae from other" do
405
+ other[:revenue] = proc{|r| r[:price] * r[:quantity]}
406
+ result = sales | other
407
+ _(result.map{|row| row[:revenue]}).must_equal [1000.0, 1500.0, 1000.0, 1500.0, 50.0]
408
+ end
409
+
410
+ it "prefers self's formulae on conflict" do
411
+ sales[:label] = proc{|r| "self: #{r[:product]}"}
412
+ other[:label] = proc{|r| "other: #{r[:product]}"}
413
+ result = sales | other
414
+ _(result.map{|row| row[:label]}).must_equal [
415
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget', 'self: Thingo'
416
+ ]
417
+ end
418
+
419
+ it "raises when dimensions differ" do
420
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
421
+ _ { sales | other }.must_raise ArgumentError
422
+ end
423
+ end
424
+
425
+ describe "#^" do
426
+ let(:other) do
427
+ Namo.new([
428
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
429
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60},
430
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
431
+ ])
432
+ end
433
+
434
+ it "returns rows in one but not both" do
435
+ result = sales ^ other
436
+ _(result.to_a).must_equal [
437
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
438
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
439
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
440
+ ]
441
+ end
442
+
443
+ it "returns empty when both are identical" do
444
+ result = sales ^ sales
445
+ _(result.to_a).must_equal []
446
+ end
447
+
448
+ it "returns all rows when nothing overlaps" do
449
+ other = Namo.new([{product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}])
450
+ result = sales ^ other
451
+ _(result.to_a.count).must_equal 5
452
+ end
453
+
454
+ it "merges formulae with self winning on conflict" do
455
+ sales[:label] = proc{|r| "self: #{r[:product]}"}
456
+ other[:label] = proc{|r| "other: #{r[:product]}"}
457
+ result = sales ^ other
458
+ _(result.map{|row| row[:label]}).must_equal [
459
+ 'self: Widget', 'self: Gadget', 'self: Thingo'
460
+ ]
461
+ end
462
+
463
+ it "raises when dimensions differ" do
464
+ other = Namo.new([{product: 'Widget', quarter: 'Q1'}])
465
+ _ { sales ^ other }.must_raise ArgumentError
466
+ end
467
+ end
468
+
254
469
  describe "#to_a" do
255
470
  it "returns the data as an array of hashes" do
256
471
  _(sales.to_a).must_equal sample_data
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.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran