namo.rb 0.31.7

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.
data/lib/namo.rb ADDED
@@ -0,0 +1,433 @@
1
+ # namo.rb
2
+ # Namo
3
+
4
+ require_relative './Namo/NegatedDimension'
5
+ require_relative './Namo/Formulary'
6
+ require_relative './Namo/Formulae'
7
+ require_relative './Namo/Row'
8
+ require_relative './Namo/Collection'
9
+ require_relative './Namo/Enumerable'
10
+ require_relative './Namo/VERSION'
11
+ require_relative './Symbol'
12
+
13
+ class Namo
14
+ include Namo::Enumerable
15
+
16
+ attr_accessor :data
17
+ attr_accessor :formulae
18
+ attr_accessor :name
19
+
20
+ INSPECTED_ROWS = 10
21
+
22
+ def dimensions
23
+ data_dimensions + derived_dimensions
24
+ end
25
+
26
+ def data_dimensions
27
+ @data.first&.keys || []
28
+ end
29
+
30
+ def derived_dimensions
31
+ @formulae.keys
32
+ end
33
+
34
+ def values(*dims)
35
+ materialising do
36
+ if dims.empty?
37
+ materialisable_dimensions.each_with_object({}){|dim, hash| hash[dim] = values_for(dim)}
38
+ elsif dims.length == 1
39
+ values_for(dims.first)
40
+ else
41
+ dims.each_with_object({}){|dim, hash| hash[dim] = values_for(dim)}
42
+ end
43
+ end
44
+ end
45
+
46
+ def coordinates(*dims)
47
+ if dims.empty?
48
+ values.transform_values(&:uniq)
49
+ elsif dims.length == 1
50
+ values(dims.first).uniq
51
+ else
52
+ dims.each_with_object({}){|dim, hash| hash[dim] = values(dim).uniq}
53
+ end
54
+ end
55
+
56
+ def to_h
57
+ values
58
+ end
59
+
60
+ def [](*names, **selections)
61
+ rows = selections.any? ? select{|row| row.match?(selections)} : entries
62
+ negated, positive = names.partition{|n| n.is_a?(NegatedDimension)}
63
+ if negated.any? && positive.any?
64
+ raise ArgumentError, "cannot mix projection and contraction in a single call"
65
+ end
66
+ projected = (
67
+ if negated.any?
68
+ excluded = negated.map(&:name)
69
+ kept = data_dimensions - excluded
70
+ rows.map do |row|
71
+ kept.each_with_object({}){|name, hash| hash[name] = row[name]}
72
+ end
73
+ elsif positive.any?
74
+ rows.map do |row|
75
+ positive.each_with_object({}){|name, hash| hash[name] = row[name]}
76
+ end
77
+ else
78
+ rows.map(&:to_h)
79
+ end
80
+ )
81
+ carried = positive.any? ? @formulae.reject{|name, _| positive.include?(name)} : @formulae.dup
82
+ return_class.new(projected, formulae: carried)
83
+ end
84
+
85
+ def []=(name, value)
86
+ if value.respond_to?(:call)
87
+ @data.each{|row| row.delete(name)} if @data.first&.key?(name)
88
+ @formulae[name] = value
89
+ else
90
+ @formulae.delete(name)
91
+ @data.each{|row| row[name] = value}
92
+ end
93
+ end
94
+
95
+ def attach(modul)
96
+ collisions = attach_collisions(modul)
97
+ unless collisions.empty?
98
+ raise ArgumentError, "formulary methods collide with data dimensions: #{collisions.inspect}"
99
+ end
100
+ @formulae.attach(modul)
101
+ self
102
+ end
103
+
104
+ def attach!(modul)
105
+ attach_collisions(modul).each{|name| @data.each{|row| row.delete(name)}}
106
+ @formulae.attach(modul)
107
+ self
108
+ end
109
+
110
+ def detach(constituent)
111
+ @formulae.detach(constituent)
112
+ self
113
+ end
114
+
115
+ def <<(constituent)
116
+ case constituent
117
+ when Module then attach(constituent)
118
+ when Row then add_row(constituent.to_h)
119
+ when Hash then add_row(constituent)
120
+ else raise TypeError, "can't append #{constituent.class} to a Namo; expected a Module (formulary), a Hash, or a Row"
121
+ end
122
+ end
123
+
124
+ def +(other)
125
+ raise_unless_namo(other)
126
+ raise_unless_matching_data_dimensions(other)
127
+ return_class.new(@data + other.data, formulae: other.formulae.merge(@formulae))
128
+ end
129
+
130
+ def -(other)
131
+ raise_unless_namo(other)
132
+ raise_unless_matching_data_dimensions(other)
133
+ return_class.new(@data - other.data, formulae: @formulae.dup)
134
+ end
135
+
136
+ def &(other)
137
+ raise_unless_namo(other)
138
+ raise_unless_matching_data_dimensions(other)
139
+ return_class.new(@data & other.data, formulae: @formulae.dup)
140
+ end
141
+
142
+ def |(other)
143
+ raise_unless_namo(other)
144
+ raise_unless_matching_data_dimensions(other)
145
+ return_class.new((@data | other.data), formulae: other.formulae.merge(@formulae))
146
+ end
147
+
148
+ def ^(other)
149
+ raise_unless_namo(other)
150
+ raise_unless_matching_data_dimensions(other)
151
+ return_class.new((@data - other.data) + (other.data - @data), formulae: other.formulae.merge(@formulae))
152
+ end
153
+
154
+ def *(other, &block)
155
+ raise_unless_namo(other)
156
+ raise_unless_shared_data_dimensions(other)
157
+ raise_unless_data_formula_exclusivity(other)
158
+ shared = data_dimensions & other.data_dimensions
159
+ combined_data = []
160
+ @data.each do |left_row|
161
+ matched = other.data.select{|right_row| shared.all?{|dim| left_row[dim] == right_row[dim]}}
162
+ if block
163
+ candidates = other.class.new(matched, formulae: other.formulae.dup)
164
+ chosen = block.call(Row.new(left_row, @formulae, self), candidates)
165
+ chosen.data.each{|right_row| combined_data << left_row.merge(right_row)}
166
+ else
167
+ matched.each{|right_row| combined_data << left_row.merge(right_row)}
168
+ end
169
+ end
170
+ return_class.new(combined_data, formulae: other.formulae.merge(@formulae))
171
+ end
172
+
173
+ def **(other, &block)
174
+ raise_unless_namo(other)
175
+ raise_unless_disjoint_data_dimensions(other)
176
+ raise_unless_data_formula_exclusivity(other)
177
+ combined_data = []
178
+ @data.each do |left_row|
179
+ if block
180
+ candidates = other.class.new(other.data, formulae: other.formulae.dup)
181
+ chosen = block.call(Row.new(left_row, @formulae, self), candidates)
182
+ chosen.data.each{|right_row| combined_data << left_row.merge(right_row)}
183
+ else
184
+ other.data.each{|right_row| combined_data << left_row.merge(right_row)}
185
+ end
186
+ end
187
+ return_class.new(combined_data, formulae: other.formulae.merge(@formulae))
188
+ end
189
+
190
+ def /(other)
191
+ raise_unless_namo(other)
192
+ kept = data_dimensions - other.data_dimensions
193
+ projected = @data.map do |row|
194
+ kept.each_with_object({}){|dim, hash| hash[dim] = row[dim]}
195
+ end
196
+ return_class.new(projected.uniq, formulae: @formulae.dup)
197
+ end
198
+
199
+ def ==(other)
200
+ return false unless other.is_a?(Namo)
201
+ row_multiset == other.row_multiset
202
+ end
203
+
204
+ def ===(other)
205
+ return false unless other.is_a?(Namo)
206
+ dimensions.sort == other.dimensions.sort &&
207
+ @formulae.keys.sort == other.formulae.keys.sort
208
+ end
209
+
210
+ def eql?(other)
211
+ self.class == other.class &&
212
+ row_multiset == other.row_multiset &&
213
+ @formulae.keys.sort == other.formulae.keys.sort
214
+ end
215
+
216
+ def hash
217
+ [self.class, row_multiset, @formulae.keys.sort].hash
218
+ end
219
+
220
+ def <(other)
221
+ raise_unless_namo(other)
222
+ raise_unless_matching_data_dimensions(other)
223
+ proper_subset_of_rows?(other)
224
+ end
225
+
226
+ def <=(other)
227
+ raise_unless_namo(other)
228
+ raise_unless_matching_data_dimensions(other)
229
+ subset_of_rows?(other)
230
+ end
231
+
232
+ def >(other)
233
+ raise_unless_namo(other)
234
+ raise_unless_matching_data_dimensions(other)
235
+ other.proper_subset_of_rows?(self)
236
+ end
237
+
238
+ def >=(other)
239
+ raise_unless_namo(other)
240
+ raise_unless_matching_data_dimensions(other)
241
+ other.subset_of_rows?(self)
242
+ end
243
+
244
+ # Each row's own keys, in their own order, and then the derived dimensions which
245
+ # can be materialised without arguments — the rule values, coordinates and to_h
246
+ # already follow. data is the stored rows, and stays so.
247
+ def to_a
248
+ materialising do
249
+ derived = materialisable_dimensions - data_dimensions
250
+ @data.map do |row|
251
+ subject = Row.new(row, @formulae, self)
252
+ stored = row.keys.each_with_object({}){|key, hash| hash[key] = row[key]}
253
+ derived.each_with_object(stored){|dimension, hash| hash[dimension] = subject[dimension]}
254
+ end
255
+ end
256
+ end
257
+
258
+ def inspect
259
+ rendered = @data.first(INSPECTED_ROWS).map{|row| [row, inspected_derivations(row)]}
260
+ "#<#{self.class}#{inspected_name}#{inspected_rows(rendered)}#{inspected_derived(rendered)}>"
261
+ end
262
+
263
+ protected
264
+
265
+ # The class an operation returns. A subclass is its own kind of thing, so a
266
+ # projection of a PriceData is a PriceData; Namo::Collection is not, its kind
267
+ # being its members, which a row operation carries none of.
268
+ def return_class
269
+ self.class
270
+ end
271
+
272
+ # The rendered rows, the count of those not shown, and the derived dimensions
273
+ # none of them carries. Shared with Collection, which renders its members' rows
274
+ # rather than its own and must report what a member would report of itself.
275
+ def inspected_data(limit)
276
+ rendered = @data.first(limit).map{|row| [row, inspected_derivations(row)]}
277
+ [rendered.map{|row, derived| row.merge(derived).inspect},
278
+ @data.length - limit,
279
+ unshown_derived(rendered)]
280
+ end
281
+
282
+ def row_multiset
283
+ @data.tally
284
+ end
285
+
286
+ def subset_of_rows?(other)
287
+ self_counts = row_multiset
288
+ other_counts = other.row_multiset
289
+ self_counts.all?{|row, count| (other_counts[row] || 0) >= count}
290
+ end
291
+
292
+ def proper_subset_of_rows?(other)
293
+ subset_of_rows?(other) && self != other
294
+ end
295
+
296
+ private
297
+
298
+ def initialize(positional_data = nil, data: [], formulae: {}, name: nil)
299
+ @data = positional_data || data
300
+ @formulae = formulae.is_a?(Formulae) ? formulae : Formulae.new(formulae)
301
+ @name = name
302
+ attach_included_formularies
303
+ end
304
+
305
+ def inspected_name
306
+ @name.nil? ? '' : " #{@name.inspect}"
307
+ end
308
+
309
+ # The rows as they are stored, never the derived values: inspect is called
310
+ # for every result in a console, and evaluating a formula there would cost a
311
+ # pass over the data per access and raise whatever the formula raises.
312
+ def inspected_rows(rendered)
313
+ return ' []' if @data.empty?
314
+ shown = rendered.map{|row, derived| " #{row.merge(derived).inspect}"}.join(",\n")
315
+ shown += "\n ... #{@data.length - INSPECTED_ROWS} more rows" if @data.length > INSPECTED_ROWS
316
+ " [\n#{shown}\n]"
317
+ end
318
+
319
+ # A derived dimension is shown with its value, in among the stored ones. A
320
+ # formula wanting arguments has no value to show without them, and one which
321
+ # raises has none to show either; the derived list still names both.
322
+ def inspected_derivations(row)
323
+ subject = Row.new(row, @formulae, self)
324
+ derived_dimensions.each_with_object({}) do |dimension, derived|
325
+ next if @formulae.required_parameter_count(dimension) > 2
326
+ derived[dimension] = subject[dimension]
327
+ rescue StandardError
328
+ next
329
+ end
330
+ end
331
+
332
+ # Only those the rows above do not already carry. A formula which raises, and a
333
+ # parameterised one which cannot be materialised without its arguments, render
334
+ # no value and would otherwise be nowhere in the output at all. A Collection
335
+ # renders no rows of its own and so passes none, naming every one of them.
336
+ def inspected_derived(rendered = [])
337
+ inspected_derived_names(unshown_derived(rendered))
338
+ end
339
+
340
+ def inspected_derived_names(unshown)
341
+ unshown.empty? ? '' : " derived: #{unshown.inspect}"
342
+ end
343
+
344
+ def unshown_derived(rendered)
345
+ derived_dimensions.reject do |dimension|
346
+ rendered.any? && rendered.all?{|_, derived| derived.key?(dimension)}
347
+ end
348
+ end
349
+
350
+ def add_row(row)
351
+ collisions = row.keys & derived_dimensions
352
+ unless collisions.empty?
353
+ raise ArgumentError, "row keys collide with formulae: #{collisions.inspect}"
354
+ end
355
+ @data << row
356
+ self
357
+ end
358
+
359
+ def attach_collisions(modul)
360
+ modul.public_instance_methods(false) & data_dimensions
361
+ end
362
+
363
+ def attach_included_formularies
364
+ self.class.ancestors.reverse.each do |modul|
365
+ next if modul.is_a?(Class) || !modul.include?(Namo::Formulary)
366
+ attach(modul)
367
+ end
368
+ end
369
+
370
+ def materialising
371
+ return yield if @materialised
372
+ @materialised = {}
373
+ begin
374
+ yield
375
+ ensure
376
+ @materialised = nil
377
+ end
378
+ end
379
+
380
+ def values_for(dim)
381
+ @materialised[dim] ||= (
382
+ if data_dimensions.include?(dim)
383
+ @data.map{|row_data| row_data[dim]}
384
+ else
385
+ @data.map{|row_data| Row.new(row_data, @formulae, self)[dim]}
386
+ end
387
+ )
388
+ end
389
+
390
+ def materialisable_dimensions
391
+ dimensions.reject{|dim| requires_arguments?(dim)}
392
+ end
393
+
394
+ def requires_arguments?(name)
395
+ formula = @formulae[name]
396
+ !!formula && required_parameter_count(formula) > 2
397
+ end
398
+
399
+ def required_parameter_count(formula)
400
+ formula.arity >= 0 ? formula.arity : -formula.arity - 1
401
+ end
402
+
403
+ def raise_unless_namo(other)
404
+ unless other.is_a?(Namo)
405
+ raise TypeError, "can't compare Namo with #{other.class}"
406
+ end
407
+ end
408
+
409
+ def raise_unless_matching_data_dimensions(other)
410
+ unless data_dimensions == other.data_dimensions
411
+ raise ArgumentError, "dimensions don't match: #{data_dimensions} vs #{other.data_dimensions}"
412
+ end
413
+ end
414
+
415
+ def raise_unless_shared_data_dimensions(other)
416
+ if (data_dimensions & other.data_dimensions).empty?
417
+ raise ArgumentError, "no shared dimensions, need to have shared dimensions: #{data_dimensions} vs #{other.data_dimensions}"
418
+ end
419
+ end
420
+
421
+ def raise_unless_disjoint_data_dimensions(other)
422
+ if (data_dimensions & other.data_dimensions).any?
423
+ raise ArgumentError, "dimensions in common, need no common dimensions: #{data_dimensions} vs #{other.data_dimensions}"
424
+ end
425
+ end
426
+
427
+ def raise_unless_data_formula_exclusivity(other)
428
+ collisions = (data_dimensions & other.derived_dimensions) | (derived_dimensions & other.data_dimensions)
429
+ if collisions.any?
430
+ raise ArgumentError, "name collision between data and formulae: #{collisions.inspect}"
431
+ end
432
+ end
433
+ end
data/namo.rb.gemspec ADDED
@@ -0,0 +1,42 @@
1
+ # namo.rb.gemspec
2
+
3
+ require_relative './lib/Namo/VERSION'
4
+
5
+ class Gem::Specification
6
+ def development_dependencies=(gems)
7
+ gems.each{|gem| add_development_dependency(*gem)}
8
+ end
9
+ end
10
+
11
+ Gem::Specification.new do |spec|
12
+ spec.name = 'namo.rb'
13
+ spec.version = Namo::VERSION
14
+
15
+ spec.summary = "Named dimensional data for Ruby."
16
+ spec.description = "A Ruby library for working with multi-dimensional data using named dimensions. Initialise from an array of hashes making it trivial to use with databases, CSV, JSON, and YAML. Dimensions and coordinates are inferred automatically."
17
+
18
+ spec.author = 'thoran'
19
+ spec.email = 'code@thoran.com'
20
+ spec.homepage = 'https://github.com/thoran/namo'
21
+ spec.license = 'MIT'
22
+
23
+ spec.required_ruby_version = '>= 2.7'
24
+ spec.require_paths = ['lib']
25
+
26
+ spec.files = [
27
+ Dir['lib/**/*.rb'],
28
+ Dir['test/**/*.rb'],
29
+ 'CHANGELOG',
30
+ 'Gemfile',
31
+ 'LICENSE',
32
+ 'namo.rb.gemspec',
33
+ 'Rakefile',
34
+ 'README.md',
35
+ ].flatten
36
+
37
+ spec.development_dependencies = %w{
38
+ minitest
39
+ minitest-spec-context
40
+ rake
41
+ }
42
+ end