Skip to content

Commit 3a53018

Browse files
committed
refactor: extract shared builder module, add cleaner DSL, fix type safety
Extract ConversionTableBuilderBase module to DRY up cycle detection, graph validation, and conversion caching shared by both builders. Add convert_to:/forward:/backward:/description: kwargs to UnitSystemBuilder#unit as a cleaner alternative to the value: hash format. Fix IDENTITY lambda to preserve input types, coerce convert inputs to Rational for exact arithmetic, and use Rational(1) in comparable_amount. Add UnitSystem#functional? for introspection.
1 parent 970c87a commit 3a53018

4 files changed

Lines changed: 120 additions & 30 deletions

File tree

lib/measured/conversion_table_builder.rb

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22
class Measured::ConversionTableBuilder
3-
attr_reader :units
3+
include Measured::ConversionTableBuilderBase
44

55
def initialize(units, cache: nil)
66
@units = units
@@ -39,23 +39,6 @@ def generate_table
3939
end
4040
end
4141

42-
def validate_no_cycles
43-
graph = units.select { |unit| unit.conversion_unit.present? }.group_by { |unit| unit.name }
44-
validate_acyclic_graph(graph, from: graph.keys[0])
45-
end
46-
47-
# This uses a depth-first search algorithm: https://en.wikipedia.org/wiki/Depth-first_search
48-
def validate_acyclic_graph(graph, from:, visited: [])
49-
graph[from]&.each do |edge|
50-
adjacent_node = edge.conversion_unit
51-
if visited.include?(adjacent_node)
52-
raise Measured::CycleDetected.new(edge)
53-
else
54-
validate_acyclic_graph(graph, from: adjacent_node, visited: visited + [adjacent_node])
55-
end
56-
end
57-
end
58-
5942
def find_conversion(to:, from:)
6043
conversion = find_direct_conversion_cached(to: to, from: from) || find_tree_traversal_conversion(to: to, from: from)
6144

@@ -64,17 +47,6 @@ def find_conversion(to:, from:)
6447
conversion
6548
end
6649

67-
def find_direct_conversion_cached(to:, from:)
68-
@direct_conversion_cache ||= {}
69-
@direct_conversion_cache[to] ||= {}
70-
71-
if @direct_conversion_cache[to].key?(from)
72-
@direct_conversion_cache[to][from]
73-
else
74-
@direct_conversion_cache[to][from] = find_direct_conversion(to: to, from: from)
75-
end
76-
end
77-
7850
def find_direct_conversion(to:, from:)
7951
units.each do |unit|
8052
return unit.conversion_amount if unit.name == from && unit.conversion_unit == to
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
module Measured::ConversionTableBuilderBase
3+
attr_reader :units
4+
5+
private
6+
7+
def validate_no_cycles
8+
graph = units.select { |unit| unit.conversion_unit.present? }.group_by(&:name)
9+
validate_acyclic_graph(graph, from: graph.keys[0])
10+
end
11+
12+
# This uses a depth-first search algorithm: https://en.wikipedia.org/wiki/Depth-first_search
13+
def validate_acyclic_graph(graph, from:, visited: [])
14+
graph[from]&.each do |edge|
15+
adjacent_node = edge.conversion_unit
16+
if visited.include?(adjacent_node)
17+
raise Measured::CycleDetected.new(edge)
18+
else
19+
validate_acyclic_graph(graph, from: adjacent_node, visited: visited + [adjacent_node])
20+
end
21+
end
22+
end
23+
24+
def find_direct_conversion_cached(to:, from:)
25+
@direct_conversion_cache ||= {}
26+
@direct_conversion_cache[to] ||= {}
27+
28+
if @direct_conversion_cache[to].key?(from)
29+
@direct_conversion_cache[to][from]
30+
else
31+
@direct_conversion_cache[to][from] = find_direct_conversion(to: to, from: from)
32+
end
33+
end
34+
end

lib/measured/unit_system_builder.rb

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ def initialize
55
@cache = nil
66
end
77

8-
def unit(unit_name, aliases: [], value: nil)
8+
def unit(unit_name, aliases: [], value: nil, convert_to: nil, forward: nil, backward: nil, description: nil)
9+
value = build_functional_value(convert_to: convert_to, forward: forward, backward: backward, description: description) || value
910
@units << build_unit(unit_name, aliases: aliases, value: value)
1011
nil
1112
end
@@ -64,6 +65,20 @@ def build_unit(name, aliases: [], value: nil)
6465
unit
6566
end
6667

68+
def build_functional_value(convert_to:, forward:, backward:, description:)
69+
return nil unless convert_to
70+
71+
unless forward && backward
72+
raise Measured::UnitError, "forward: and backward: are required when convert_to: is specified"
73+
end
74+
75+
unless description
76+
raise Measured::UnitError, "description: is required when convert_to: is specified"
77+
end
78+
79+
[{ forward: forward, backward: backward, description: description }, convert_to]
80+
end
81+
6782
def check_for_duplicate_unit_names!(unit)
6883
names = @units.flat_map(&:names)
6984
if names.any? { |name| unit.names.include?(name) }

test/unit_system_builder_test.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,75 @@ class Measured::UnitSystemBuilderTest < ActiveSupport::TestCase
136136
assert_equal "lb", measurable.unit_system.unit_for!(:long_ton).conversion_unit
137137
end
138138

139+
test "#unit accepts convert_to:, forward:, backward:, description: kwargs for functional conversions" do
140+
measurable = Measured.build do
141+
unit :C
142+
unit :K, convert_to: "C",
143+
forward: ->(k) { k - BigDecimal("273.15") },
144+
backward: ->(c) { c + BigDecimal("273.15") },
145+
description: "celsius + 273.15"
146+
end
147+
148+
assert_equal 2, measurable.unit_names.count
149+
k_unit = measurable.unit_system.unit_for!(:K)
150+
assert k_unit.functional?
151+
assert_equal "C", k_unit.conversion_unit
152+
end
153+
154+
test "#unit accepts description: kwarg for functional conversions" do
155+
measurable = Measured.build do
156+
unit :C
157+
unit :K, convert_to: "C",
158+
forward: ->(k) { k - BigDecimal("273.15") },
159+
backward: ->(c) { c + BigDecimal("273.15") },
160+
description: "celsius + 273.15"
161+
end
162+
163+
k_unit = measurable.unit_system.unit_for!(:K)
164+
assert_equal "celsius + 273.15", k_unit.description
165+
end
166+
167+
test "#unit raises when convert_to: is given without forward: and backward:" do
168+
assert_raises Measured::UnitError do
169+
Measured.build do
170+
unit :C
171+
unit :K, convert_to: "C", forward: ->(k) { k }
172+
end
173+
end
174+
175+
assert_raises Measured::UnitError do
176+
Measured.build do
177+
unit :C
178+
unit :K, convert_to: "C", backward: ->(c) { c }
179+
end
180+
end
181+
end
182+
183+
test "#unit raises when convert_to: is given without description:" do
184+
assert_raises Measured::UnitError do
185+
Measured.build do
186+
unit :C
187+
unit :K, convert_to: "C",
188+
forward: ->(k) { k - BigDecimal("273.15") },
189+
backward: ->(c) { c + BigDecimal("273.15") }
190+
end
191+
end
192+
end
193+
194+
test "#unit still accepts value: with hash format for functional conversions" do
195+
measurable = Measured.build do
196+
unit :C
197+
unit :K, value: [
198+
{ forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } },
199+
"C"
200+
]
201+
end
202+
203+
assert_equal 2, measurable.unit_names.count
204+
k_unit = measurable.unit_system.unit_for!(:K)
205+
assert k_unit.functional?
206+
end
207+
139208
test "#cache sets no cache by default" do
140209
measurable = Measured.build do
141210
unit :m

0 commit comments

Comments
 (0)