Skip to content

Commit c6f7bb1

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 c6f7bb1

4 files changed

Lines changed: 106 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: 14 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,18 @@ 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+
opts = { forward: forward, backward: backward }
76+
opts[:description] = description if description
77+
[opts, convert_to]
78+
end
79+
6780
def check_for_duplicate_unit_names!(unit)
6881
names = @units.flat_map(&:names)
6982
if names.any? { |name| unit.names.include?(name) }

test/unit_system_builder_test.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,63 @@ 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: 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+
end
146+
147+
assert_equal 2, measurable.unit_names.count
148+
k_unit = measurable.unit_system.unit_for!(:K)
149+
assert k_unit.functional?
150+
assert_equal "C", k_unit.conversion_unit
151+
end
152+
153+
test "#unit accepts description: kwarg for functional conversions" do
154+
measurable = Measured.build do
155+
unit :C
156+
unit :K, convert_to: "C",
157+
forward: ->(k) { k - BigDecimal("273.15") },
158+
backward: ->(c) { c + BigDecimal("273.15") },
159+
description: "celsius + 273.15"
160+
end
161+
162+
k_unit = measurable.unit_system.unit_for!(:K)
163+
assert_equal "celsius + 273.15", k_unit.description
164+
end
165+
166+
test "#unit raises when convert_to: is given without forward: and backward:" do
167+
assert_raises Measured::UnitError do
168+
Measured.build do
169+
unit :C
170+
unit :K, convert_to: "C", forward: ->(k) { k }
171+
end
172+
end
173+
174+
assert_raises Measured::UnitError do
175+
Measured.build do
176+
unit :C
177+
unit :K, convert_to: "C", backward: ->(c) { c }
178+
end
179+
end
180+
end
181+
182+
test "#unit still accepts value: with hash format for functional conversions" do
183+
measurable = Measured.build do
184+
unit :C
185+
unit :K, value: [
186+
{ forward: ->(k) { k - BigDecimal("273.15") }, backward: ->(c) { c + BigDecimal("273.15") } },
187+
"C"
188+
]
189+
end
190+
191+
assert_equal 2, measurable.unit_names.count
192+
k_unit = measurable.unit_system.unit_for!(:K)
193+
assert k_unit.functional?
194+
end
195+
139196
test "#cache sets no cache by default" do
140197
measurable = Measured.build do
141198
unit :m

0 commit comments

Comments
 (0)