diff --git a/Gemfile b/Gemfile index 3ca96ab..4cc366a 100644 --- a/Gemfile +++ b/Gemfile @@ -6,10 +6,14 @@ source "https://rubygems.org" gemspec gem "canon" -gem "lutaml-model", "~> 0.8.0", github: "lutaml/lutaml-model", branch: "main" +gem "lutaml-model", + github: "lutaml/lutaml-model", + branch: "fix/global-context-register-lookup-fallback" +gem "mml" gem "oga" gem "ox" -gem "plurimath", github: "plurimath/plurimath", branch: "rt-lutaml-080" +gem "plurimath", github: "plurimath/plurimath", + branch: "feat/autoload-and-mml-update" gem "pry" gem "rake" gem "rspec" @@ -18,3 +22,7 @@ gem "rubocop-performance" gem "rubocop-rake" gem "rubocop-rspec" gem "simplecov" +gem "unitsdb", + github: "unitsml/unitsdb-ruby", + branch: "feat/context-register-models", + submodules: true diff --git a/docs/README.adoc b/docs/README.adoc index b55c7e9..d69b7e4 100644 --- a/docs/README.adoc +++ b/docs/README.adoc @@ -40,8 +40,22 @@ The UnitsML Ruby library consists of several key components: * Prefix: Represents unit prefixes (k, m, μ, etc.) * Dimension: Represents physical dimensions -The library also includes the full https://github.com/unitsml/unitsdb[UnitsDB] -units database, which contains standard units, prefixes, and dimensions. +The library uses the https://github.com/unitsml/unitsdb-ruby[unitsdb-ruby] +gem as the primary runtime source for standard units, prefixes, and dimensions. + +== Development setup + +The development `Gemfile` pins the current integration branches for +`lutaml-model`, `mml`, `plurimath`, and `unitsdb-ruby`. If you need to test +coordinated local changes across those repositories, temporarily switch those +dependencies to Bundler `path` entries that point at sibling checkouts, for +example `../lutaml-model` or `../unitsdb-ruby`. Keep those local path changes +out of commits unless the branch intentionally requires them. + +The standard Ruby runtime path goes through `::Unitsdb.database`. When +`unitsdb-ruby` cannot load its bundled `data/` directory, UnitsML falls back to +the packaged YAML files in the `unitsdb-ruby` gem's `vendor/unitsdb` +directory, rather than a `vendor/unitsdb` directory in this repository. == Usage diff --git a/lib/unitsml.rb b/lib/unitsml.rb index 47d40c4..a1622ca 100644 --- a/lib/unitsml.rb +++ b/lib/unitsml.rb @@ -1,18 +1,20 @@ # frozen_string_literal: true require "lutaml/model" -require "unitsdb" +require "mml" module Unitsml module_function autoload :Dimension, "unitsml/dimension" + autoload :Configuration, "unitsml/configuration" autoload :Errors, "unitsml/errors" autoload :Extender, "unitsml/extender" autoload :Fenced, "unitsml/fenced" autoload :FencedNumeric, "unitsml/fenced_numeric" autoload :Formula, "unitsml/formula" autoload :IntermediateExpRules, "unitsml/intermediate_exp_rules" + autoload :MathmlHelper, "unitsml/mathml_helper" autoload :Model, "unitsml/model" autoload :Namespace, "unitsml/namespace" autoload :Number, "unitsml/number" @@ -26,54 +28,11 @@ module Unitsml autoload :Utility, "unitsml/utility" autoload :VERSION, "unitsml/version" - REGISTER_ID = :unitsml_ruby - def parse(string) Unitsml::Parser.new(string).parse end - - def register - @register ||= Lutaml::Model::GlobalRegister.lookup(REGISTER_ID) - end - - def register_model(klass, id:) - register.register_model(klass, id: id) - end - - def get_class_from_register(class_name) - register.get_class(class_name) - end - - def register_type_substitution(from:, to:) - register.register_global_type_substitution( - from_type: from, - to_type: to, - ) - end -end - -Lutaml::Model::GlobalRegister.register( - Lutaml::Model::Register.new(Unitsml::REGISTER_ID), -) - -{ - Unitsdb::Unit => Unitsml::Unitsdb::Unit, - Unitsdb::Units => Unitsml::Unitsdb::Units, - Unitsdb::Prefixes => Unitsml::Unitsdb::Prefixes, - Unitsdb::Dimension => Unitsml::Unitsdb::Dimension, - Unitsdb::PrefixReference => Unitsml::Unitsdb::PrefixReference, - Unitsdb::DimensionDetails => Unitsml::Unitsdb::DimensionQuantity, -}.each do |key, value| - Unitsml.register_type_substitution(from: key, to: value) end -[ - [Unitsml::Unitsdb::Dimensions, :unitsdb_dimensions], - [Unitsml::Unitsdb::Prefixes, :unitsdb_prefixes], - [Unitsml::Unitsdb::Quantities, :unitsdb_quantities], - [Unitsml::Unitsdb::Units, :unitsdb_units], -].each { |klass, id| Unitsml.register_model(klass, id: id) } - Lutaml::Model::Config.configure do |config| config.xml_adapter_type = RUBY_ENGINE == "opal" ? :oga : :ox end diff --git a/lib/unitsml/configuration.rb b/lib/unitsml/configuration.rb new file mode 100644 index 0000000..882876a --- /dev/null +++ b/lib/unitsml/configuration.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "unitsdb" + +module Unitsml + module Configuration + CONTEXT_ID = :unitsml_ruby + + module_function + + def context_id + CONTEXT_ID + end + + def context(force_populate: false) + existing = ::Unitsdb::Config.find_context(context_id) + return existing if existing && !force_populate + + build_context + end + + def register_model(klass, id:) + registered_models[id.to_sym] = klass + end + + def registered_models + @registered_models ||= {} + end + + def build_context + ::Unitsdb::Config.context # ensure unitsdb context exists + + substitutions = registered_models.each_value.filter_map do |klass| + parent = klass.superclass + next if parent == Object + + { from_type: parent, to_type: klass } + end + + ::Unitsdb::Config.populate_context( + id: context_id, + fallback_to: [::Unitsdb::Config.context_id], + substitutions: substitutions, + ) + end + end +end diff --git a/lib/unitsml/dimension.rb b/lib/unitsml/dimension.rb index 50fc686..029144a 100644 --- a/lib/unitsml/dimension.rb +++ b/lib/unitsml/dimension.rb @@ -2,6 +2,8 @@ module Unitsml class Dimension + include MathmlHelper + attr_accessor :dimension_name, :power_numerator def initialize(dimension_name, power_numerator = nil) @@ -26,7 +28,7 @@ def dim_symbols def to_mathml(options) # MathML key's value in unitsdb/dimensions.yaml # file includes mi tags only. - value = ::Mml::V4::Mi.from_xml(dim_symbols.mathml) + value = mml_v4_from_xml(:mi, dim_symbols.mathml) method_name = if power_numerator value = msup_tag(value, options) :msup @@ -63,7 +65,10 @@ def to_xml(_) symbol: dim_instance.processed_symbol, power_numerator: power_numerator&.raw_value || 1, } - Model::DimensionQuantities.const_get(modelize(element_name)).new(attributes) + Model::DimensionQuantities.const_get(modelize(element_name)).new( + **attributes, + lutaml_register: Configuration.context.id, + ) end def xml_instances_hash(options) @@ -94,8 +99,9 @@ def element_name def msup_tag(value, options) mathml = power_numerator.to_mathml(options) - msup = ::Mml::V4::Msup.new( - mrow_value: [::Mml::V4::Mrow.new(mi_value: [value])], + msup = mml_v4_new( + :msup, + mrow_value: [mml_v4_new(:mrow, mi_value: [value])], ) [mathml].flatten.each do |record| record_values = msup.public_send("#{record[:method_name]}_value") || [] diff --git a/lib/unitsml/errors.rb b/lib/unitsml/errors.rb index 1731e5c..42d1441 100644 --- a/lib/unitsml/errors.rb +++ b/lib/unitsml/errors.rb @@ -3,6 +3,8 @@ module Unitsml module Errors autoload :BaseError, "unitsml/errors/base_error" + autoload :OpalPayloadNotBundledError, + "unitsml/errors/opal_payload_not_bundled_error" autoload :PlurimathLoadError, "unitsml/errors/plurimath_load_error" end end diff --git a/lib/unitsml/errors/opal_payload_not_bundled_error.rb b/lib/unitsml/errors/opal_payload_not_bundled_error.rb new file mode 100644 index 0000000..02c1083 --- /dev/null +++ b/lib/unitsml/errors/opal_payload_not_bundled_error.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Unitsml + module Errors + class OpalPayloadNotBundledError < Unitsml::Errors::BaseError + def to_s + "[unitsml] Error: Opal database payload is not bundled." + end + end + end +end diff --git a/lib/unitsml/extender.rb b/lib/unitsml/extender.rb index 897f24e..f17652d 100644 --- a/lib/unitsml/extender.rb +++ b/lib/unitsml/extender.rb @@ -2,6 +2,8 @@ module Unitsml class Extender + include MathmlHelper + attr_accessor :symbol def initialize(symbol) @@ -18,7 +20,7 @@ def to_mathml(options) extender = multiplier(options[:multiplier] || "⋅", unicode: true) { method_name: :mo, - value: ::Mml::V4::Mo.new(value: extender, rspace: rspace), + value: mml_v4_new(:mo, value: extender, rspace: rspace), } end diff --git a/lib/unitsml/fenced.rb b/lib/unitsml/fenced.rb index de10929..5ec806f 100644 --- a/lib/unitsml/fenced.rb +++ b/lib/unitsml/fenced.rb @@ -3,6 +3,7 @@ module Unitsml class Fenced include FencedNumeric + include MathmlHelper attr_reader :open_paren, :value, :close_paren @@ -31,11 +32,14 @@ def to_mathml(options = {}) mathml = value.to_mathml(options) return mathml unless options[:explicit_parenthesis] - fenced = ::Mml::V4::Mrow.new(mo_value: [::Mml::V4::Mo.new(value: open_paren)]) + fenced = mml_v4_new( + :mrow, + mo_value: [mml_v4_new(:mo, value: open_paren)], + ) fenced.ordered = true fenced.element_order ||= [xml_order_element("mo")] [mathml].flatten.each { |record| add_math_element(fenced, record) } - fenced.mo_value << ::Mml::V4::Mo.new(value: close_paren) + fenced.mo_value << mml_v4_new(:mo, value: close_paren) fenced.element_order << xml_order_element("mo") { method_name: :mrow, value: fenced } end diff --git a/lib/unitsml/formula.rb b/lib/unitsml/formula.rb index f3b2373..e273ae6 100644 --- a/lib/unitsml/formula.rb +++ b/lib/unitsml/formula.rb @@ -5,6 +5,8 @@ module Unitsml class Formula + include MathmlHelper + attr_accessor :value, :explicit_value, :root def initialize(value = [], @@ -29,15 +31,14 @@ def ==(other) def to_mathml(options = {}) if root options = update_options(options) - nullify_mml_models if plurimath_available? - math = ::Mml::V4::Math.new(display: "block") + math = mml_v4_new(:math, display: "block") math.ordered = true math.element_order ||= [] value.each do |instance| process_value(math, instance.to_mathml(options)) end - generated_math = math.to_xml.gsub(%r{&(.*?)(?=\s+<").strip + end end end diff --git a/lib/unitsml/mathml_helper.rb b/lib/unitsml/mathml_helper.rb new file mode 100644 index 0000000..c7f8375 --- /dev/null +++ b/lib/unitsml/mathml_helper.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Unitsml + module MathmlHelper + module_function + + def mml_v4_context + ::Mml::V4::Configuration.context + end + + def mml_v4_from_xml(klass_ref, xml) + mml_v4_class_for(klass_ref).from_xml(xml, register: mml_v4_context.id) + end + + def mml_v4_new(klass_ref, **attributes) + klass = mml_v4_class_for(klass_ref) + coerce_mml_v4_collection_attributes!(klass, attributes) + klass.new(**attributes, lutaml_register: mml_v4_context.id) + end + + def mml_v4_with_content(instance, content) + attributes = instance.to_hash.transform_keys(&:to_sym) + mml_v4_new( + instance.class, + **attributes, + mml_v4_content_attribute(instance) => content, + ) + end + + def mml_v4_class_for(klass_ref) + return klass_ref if klass_ref.is_a?(Class) + + mml_v4_context.lookup_local(klass_ref.to_sym) + end + + private + + def coerce_mml_v4_collection_attributes!(klass, attributes) + return unless klass.respond_to?(:attributes) + + attributes.each do |name, value| + attribute = klass.attributes[name] + attributes[name] = coerce_mml_v4_collection_value(attribute, value) + end + end + + def coerce_mml_v4_collection_value(attribute, value) + return value unless attribute&.collection? + return value if value.nil? + return value if attribute.collection_instance?(value) + + attribute.build_collection(value) + end + + def mml_v4_content_attribute(instance) + register = instance.lutaml_register || mml_v4_context.id + instance.class.mappings_for(:xml, register).content_mapping.to + end + end +end diff --git a/lib/unitsml/number.rb b/lib/unitsml/number.rb index a1fa826..608abed 100644 --- a/lib/unitsml/number.rb +++ b/lib/unitsml/number.rb @@ -3,6 +3,7 @@ module Unitsml class Number include FencedNumeric + include MathmlHelper attr_accessor :value alias raw_value value @@ -19,7 +20,7 @@ def ==(other) def to_mathml(_options) matched_value = value&.match(/-?(.+)/) mn_value = matched_value ? matched_value[1] : value - mn_tag = ::Mml::V4::Mn.new(value: mn_value) + mn_tag = mml_v4_new(:mn, value: mn_value) value.start_with?("-") ? mrow_hash(mn_tag) : mn_hash(mn_tag) end @@ -62,8 +63,9 @@ def float_to_display def mrow_hash(mn_tag) { method_name: :mrow, - value: ::Mml::V4::Mrow.new( - mo_value: [::Mml::V4::Mo.new(value: "−")], + value: mml_v4_new( + :mrow, + mo_value: [mml_v4_new(:mo, value: "−")], mn_value: [mn_tag], ), } diff --git a/lib/unitsml/prefix.rb b/lib/unitsml/prefix.rb index fd17e47..e441817 100644 --- a/lib/unitsml/prefix.rb +++ b/lib/unitsml/prefix.rb @@ -2,6 +2,8 @@ module Unitsml class Prefix + include MathmlHelper + attr_accessor :prefix_name, :only_instance def initialize(prefix_name, only_instance = false) @@ -41,7 +43,7 @@ def to_mathml(_) ) return symbol unless only_instance - { method_name: :mi, value: ::Mml::V4::Mi.new(value: symbol) } + { method_name: :mi, value: mml_v4_new(:mi, value: symbol) } end def to_latex(_) diff --git a/lib/unitsml/unit.rb b/lib/unitsml/unit.rb index 4cacaae..fd65b22 100644 --- a/lib/unitsml/unit.rb +++ b/lib/unitsml/unit.rb @@ -2,6 +2,8 @@ module Unitsml class Unit + include MathmlHelper + attr_accessor :unit_name, :power_numerator, :prefix SI_UNIT_SYSTEM = %w[si_base si_derived_special @@ -35,13 +37,14 @@ def unit_symbols end def to_mathml(options) - raw_mathml = unit_symbols&.mathml - tag_name = raw_mathml.match(/^<(?\w+)/)[:tag] - klass = ::Mml::V4.const_get(tag_name.capitalize) - value = klass.from_xml(raw_mathml) + value = unit_symbols&.mathml + tag_name = value.match(/^<(?\w+)/)[:tag] + value = mml_v4_from_xml(tag_name, value) if prefix - value = with_updated_value(value, - "#{prefix.to_mathml(options)}#{value.value}") + value = mml_v4_with_content( + value, + "#{prefix.to_mathml(options.merge(parent: value))}#{Array(value.value).join}", + ) end if power_numerator value = msup_tag( @@ -130,17 +133,8 @@ def system_reference unit_instance.unit_system_reference end - def with_updated_value(element, new_value) - attrs = element.class.attributes.each_with_object({}) do |(name, _), h| - val = element.public_send(name) - h[name] = val unless val.nil? - end - attrs[:value] = new_value - element.class.new(**attrs) - end - def msup_tag(value, options) - msup = ::Mml::V4::Msup.new + msup = mml_v4_new(:msup) msup.ordered = true msup.element_order = [] [value, power_numerator.to_mathml(options)].flatten.each do |record| diff --git a/lib/unitsml/unitsdb.rb b/lib/unitsml/unitsdb.rb index 2fa60a0..7efecc2 100644 --- a/lib/unitsml/unitsdb.rb +++ b/lib/unitsml/unitsdb.rb @@ -1,32 +1,53 @@ # frozen_string_literal: true +require "unitsdb" + +require_relative "unitsdb/database" +require_relative "unitsdb/dimension_details" +require_relative "unitsdb/prefix_reference" +require_relative "unitsdb/dimension" +require_relative "unitsdb/dimensions" +require_relative "unitsdb/unit" +require_relative "unitsdb/units" +require_relative "unitsdb/prefixes" +require_relative "unitsdb/quantities" module Unitsml module Unitsdb - autoload :Unit, "#{__dir__}/unitsdb/unit" - autoload :Units, "#{__dir__}/unitsdb/units" - autoload :Prefixes, "#{__dir__}/unitsdb/prefixes" - autoload :Dimension, "#{__dir__}/unitsdb/dimension" - autoload :Dimensions, "#{__dir__}/unitsdb/dimensions" - autoload :Quantities, "#{__dir__}/unitsdb/quantities" - autoload :PrefixReference, "#{__dir__}/unitsdb/prefix_reference" - autoload :DimensionQuantity, "#{__dir__}/unitsdb/dimension_quantity" - autoload :SiDerivedBase, "#{__dir__}/unitsdb/si_derived_base" - class << self + REQUIRED_DATABASE_FILES = %w[ + prefixes.yaml + dimensions.yaml + units.yaml + quantities.yaml + unit_systems.yaml + ].freeze + def units - Units.new(units: ::Unitsdb.database.units) + @units ||= Units.new( + units: database.units, + lutaml_register: Configuration.context.id, + ) end def prefixes - Prefixes.new(prefixes: ::Unitsdb.database.prefixes) + @prefixes ||= Prefixes.new( + prefixes: database.prefixes, + lutaml_register: Configuration.context.id, + ) end def dimensions - Dimensions.new(dimensions: ::Unitsdb.database.dimensions) + @dimensions ||= Dimensions.new( + dimensions: database.dimensions, + lutaml_register: Configuration.context.id, + ) end def quantities - Quantities.new(quantities: ::Unitsdb.database.quantities) + @quantities ||= Quantities.new( + quantities: database.quantities, + lutaml_register: Configuration.context.id, + ) end def prefixes_array @@ -39,6 +60,53 @@ def prefixes_by_size(size) @sized_prefixes[size] = prefixes_array.select { |p| p.size == size } end + + def database + @database ||= load_database + end + + private + + def load_database + context_id = Configuration.context.id + + if ::Unitsdb.respond_to?(:database) + return load_unitsdb_database(context_id) + end + + Database.from_db(database_path, context: context_id) + end + + def load_unitsdb_database(context_id) + ::Unitsdb.database(context: context_id) + rescue ::Unitsdb::Errors::DatabaseNotFoundError, + ::Unitsdb::Errors::DatabaseFileNotFoundError + Database.from_db(database_path, context: context_id) + end + + def database_path + candidate_database_paths.find do |path| + database_files_present?(path) + end || + File.join(unitsdb_gem_path, "vendor", "unitsdb") + end + + def candidate_database_paths + [ + File.join(unitsdb_gem_path, "data"), + File.join(unitsdb_gem_path, "vendor", "unitsdb"), + ] + end + + def database_files_present?(dir_path) + REQUIRED_DATABASE_FILES.all? do |file_name| + File.exist?(File.join(dir_path, file_name)) + end + end + + def unitsdb_gem_path + Gem.loaded_specs.fetch("unitsdb").full_gem_path + end end end end diff --git a/lib/unitsml/unitsdb/database.rb b/lib/unitsml/unitsdb/database.rb new file mode 100644 index 0000000..7a5be53 --- /dev/null +++ b/lib/unitsml/unitsdb/database.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Unitsml + module Unitsdb + class Database < ::Unitsdb::Database + DATABASE = nil + + def self.from_db(dir_path, context: Unitsml::Configuration.context.id) + return super unless RUBY_ENGINE == "opal" + + context_id = context.to_sym + raise Unitsml::Errors::OpalPayloadNotBundledError unless DATABASE + + Unitsml::Configuration.context + + from_hash(DATABASE, register: context_id) + end + + Configuration.register_model(self, id: :database) + end + end +end diff --git a/lib/unitsml/unitsdb/dimension.rb b/lib/unitsml/unitsdb/dimension.rb index 223613f..df5a975 100644 --- a/lib/unitsml/unitsdb/dimension.rb +++ b/lib/unitsml/unitsdb/dimension.rb @@ -16,43 +16,41 @@ def initialize(attrs) end def length=(value) - quantities_common_code(:length, wrap_dimension_value(value)) + quantities_common_code(:length, value) end def mass=(value) - quantities_common_code(:mass, wrap_dimension_value(value)) + quantities_common_code(:mass, value) end def time=(value) - quantities_common_code(:time, wrap_dimension_value(value)) + quantities_common_code(:time, value) end def thermodynamic_temperature=(value) - quantities_common_code(:thermodynamic_temperature, - wrap_dimension_value(value)) + quantities_common_code(:thermodynamic_temperature, value) end def amount_of_substance=(value) - quantities_common_code(:amount_of_substance, - wrap_dimension_value(value)) + quantities_common_code(:amount_of_substance, value) end def luminous_intensity=(value) - quantities_common_code(:luminous_intensity, wrap_dimension_value(value)) + quantities_common_code(:luminous_intensity, value) end def plane_angle=(value) - quantities_common_code(:plane_angle, wrap_dimension_value(value)) + quantities_common_code(:plane_angle, value) end def electric_current=(value) - quantities_common_code(:electric_current, wrap_dimension_value(value)) + quantities_common_code(:electric_current, value) end def dim_symbols - processed_keys.map do |vec| - public_send(vec)&.dim_symbols&.map(&:id) - end.flatten.compact + processed_keys.flat_map do |vec| + dimension_symbols_for(public_send(vec)).map(&:id) + end.compact end def processed_symbol @@ -76,19 +74,24 @@ def quantities_common_code(instance_var, value) instance_variable_set(:"@#{instance_var}", value) @processed_keys << instance_var.to_s - return if Lutaml::Model::Utils.empty?(value.symbols) + dim_symbols = dimension_symbols_for(value) + return if Lutaml::Model::Utils.empty?(dim_symbols) @parsable = true - value.dim_symbols_ids(@parsables, id) + dim_symbols.each { |dim_sym| @parsables[dim_sym.id] = id } end - def wrap_dimension_value(value) - return value if value.is_a?(DimensionQuantity) - return DimensionQuantity.new(value.to_hash) if value.is_a?(::Unitsdb::DimensionDetails) - return DimensionQuantity.new(value) if value.is_a?(Hash) + def dimension_symbols_for(value) + return [] if value.nil? - value + if value.respond_to?(:dim_symbols) + Array(value.dim_symbols) + else + Array(value.symbols) + end end end + + Configuration.register_model(Dimension, id: :dimension) end end diff --git a/lib/unitsml/unitsdb/dimension_quantity.rb b/lib/unitsml/unitsdb/dimension_details.rb similarity index 59% rename from lib/unitsml/unitsdb/dimension_quantity.rb rename to lib/unitsml/unitsdb/dimension_details.rb index 8d8c420..00dbe2c 100644 --- a/lib/unitsml/unitsdb/dimension_quantity.rb +++ b/lib/unitsml/unitsdb/dimension_details.rb @@ -2,10 +2,12 @@ module Unitsml module Unitsdb - class DimensionQuantity < ::Unitsdb::DimensionDetails + class DimensionDetails < ::Unitsdb::DimensionDetails def dim_symbols_ids(hash, dim_id) symbols&.each { |dim_sym| hash[dim_sym.id] = dim_id } end end + + Configuration.register_model(DimensionDetails, id: :dimension_details) end end diff --git a/lib/unitsml/unitsdb/dimensions.rb b/lib/unitsml/unitsdb/dimensions.rb index f606ec6..373bd34 100644 --- a/lib/unitsml/unitsdb/dimensions.rb +++ b/lib/unitsml/unitsdb/dimensions.rb @@ -32,5 +32,7 @@ def find(field, matching_data) dimensions.find { |dim| dim.send(field) == matching_data } end end + + Configuration.register_model(Dimensions, id: :dimensions) end end diff --git a/lib/unitsml/unitsdb/prefix_reference.rb b/lib/unitsml/unitsdb/prefix_reference.rb index 6187094..7b919c2 100644 --- a/lib/unitsml/unitsdb/prefix_reference.rb +++ b/lib/unitsml/unitsdb/prefix_reference.rb @@ -16,10 +16,12 @@ def power end def prefix - @prefix ||= ::Unitsdb.database.prefixes.find do |p| + @prefix ||= Unitsml::Unitsdb.database.prefixes.find do |p| p.identifiers.any? { |i| i.id == id } end end end + + Configuration.register_model(PrefixReference, id: :prefix_reference) end end diff --git a/lib/unitsml/unitsdb/prefixes.rb b/lib/unitsml/unitsdb/prefixes.rb index 47ce5a2..80256b3 100644 --- a/lib/unitsml/unitsdb/prefixes.rb +++ b/lib/unitsml/unitsdb/prefixes.rb @@ -30,5 +30,7 @@ def find(matching_data, field, prefix_method) end end end + + Configuration.register_model(Prefixes, id: :prefixes) end end diff --git a/lib/unitsml/unitsdb/quantities.rb b/lib/unitsml/unitsdb/quantities.rb index 1c521f5..11383fa 100644 --- a/lib/unitsml/unitsdb/quantities.rb +++ b/lib/unitsml/unitsdb/quantities.rb @@ -9,5 +9,7 @@ def find_by_id(q_id) end end end + + Configuration.register_model(Quantities, id: :quantities) end end diff --git a/lib/unitsml/unitsdb/si_derived_base.rb b/lib/unitsml/unitsdb/si_derived_base.rb deleted file mode 100644 index 6b2f9b4..0000000 --- a/lib/unitsml/unitsdb/si_derived_base.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Unitsml - module Unitsdb - class SiDerivedBase < ::Unitsdb::SiDerivedBase - def prefix_reference=(value) - return super if value.nil? - return super if value.is_a?(PrefixReference) - - super(PrefixReference.new(value.to_hash)) - end - end - end -end diff --git a/lib/unitsml/unitsdb/unit.rb b/lib/unitsml/unitsdb/unit.rb index aa0964d..b2eaecd 100644 --- a/lib/unitsml/unitsdb/unit.rb +++ b/lib/unitsml/unitsdb/unit.rb @@ -3,14 +3,6 @@ module Unitsml module Unitsdb class Unit < ::Unitsdb::Unit - def si_derived_bases=(value) - return super if value.nil? - - super(value.map do |s| - SiDerivedBase.new(s.to_hash) - end) - end - def dimension_url quantity_id = quantity_references[0].id quantity = Unitsml::Unitsdb.quantities.find_by_id(quantity_id) @@ -24,6 +16,8 @@ def en_name def nist_id identifiers.find { |id| id.type == "nist" }&.id end + + Configuration.register_model(self, id: :unit) end end end diff --git a/lib/unitsml/unitsdb/units.rb b/lib/unitsml/unitsdb/units.rb index 7bbea64..8716839 100644 --- a/lib/unitsml/unitsdb/units.rb +++ b/lib/unitsml/unitsdb/units.rb @@ -3,10 +3,6 @@ module Unitsml module Unitsdb class Units < ::Unitsdb::Units - def units=(value) - super(value.map { |u| Unit.new(u.to_hash) }) - end - def find_by_id(u_id) find(u_id, :id, :identifiers) end @@ -45,5 +41,7 @@ def find(matching_data, field, unit_method) end end end + + Configuration.register_model(Units, id: :units) end end diff --git a/lib/unitsml/utility.rb b/lib/unitsml/utility.rb index a840b04..2033fae 100644 --- a/lib/unitsml/utility.rb +++ b/lib/unitsml/utility.rb @@ -40,6 +40,12 @@ module Utility ].freeze UNKNOWN = "unknown" + PREFIX_SYMBOL_METHODS = { + ASCII: :to_asciimath, + unicode: :to_unicode, + LaTeX: :to_latex, + HTML: :to_html, + }.freeze class << self def unit_instance(unit) @@ -139,26 +145,71 @@ def unit_numerator_float(object_hash) end def prefix_object(prefix) - return prefix unless prefix.is_a?(String) - return nil unless Unitsdb.prefixes_array.any?(prefix) + return nil if prefix.nil? + return prefix if prefix_like?(prefix) + + if prefix.is_a?(String) + return nil unless Unitsdb.prefixes_array.any?(prefix) + + return Prefix.new(prefix) + end - Prefix.new(prefix) + return Unitsdb.prefixes.find_by_id(prefix.id) if prefix.respond_to?(:id) + + prefix end def combine_prefixes(p1, p2) + p1 = prefix_object(p1) + p2 = prefix_object(p2) return nil if p1.nil? && p2.nil? - return p1.symbolid if p2.nil? - return p2.symbolid if p1.nil? - return UNKNOWN if p1.base != p2.base + return prefix_symbolid(p1) if p2.nil? + return prefix_symbolid(p2) if p1.nil? + return UNKNOWN if prefix_base(p1) != prefix_base(p2) Unitsdb.prefixes_array.each do |prefix_name| p = prefix_object(prefix_name) - return p if p.base == p1.base && p.power == p1.power + p2.power + return p if prefix_base(p) == prefix_base(p1) && + prefix_power(p) == prefix_power(p1) + prefix_power(p2) end UNKNOWN end + def prefix_like?(prefix) + prefix.respond_to?(:base) && prefix.respond_to?(:power) && + (prefix.respond_to?(:symbolid) || prefix.respond_to?(:symbols)) + end + + def prefix_symbolid(prefix) + return prefix.symbolid if prefix.respond_to?(:symbolid) + + prefix_record = resolved_prefix(prefix) + return unless prefix_record + + prefix_record.symbols&.first&.ascii + end + + def prefix_base(prefix) + return prefix.base if prefix.respond_to?(:base) + + resolved_prefix(prefix)&.base + end + + def prefix_power(prefix) + return prefix.power if prefix.respond_to?(:power) + + resolved_prefix(prefix)&.power + end + + def resolved_prefix(prefix) + return prefix if prefix.nil? + return prefix if prefix.respond_to?(:symbols) && prefix.respond_to?(:base) && prefix.respond_to?(:power) + return Unitsdb.prefixes.find_by_id(prefix.id) if prefix.respond_to?(:id) + + prefix + end + def unit(units, formula, dims, norm_text, name, options) attributes = { id: unit_id(norm_text), @@ -168,7 +219,7 @@ def unit(units, formula, dims, norm_text, name, options) root_units: rootunits(units), } attributes[:dimension_url] = "##{dim_id(dims)}" if dims - Model::Unit.new(attributes).to_xml + Model::Unit.new(**attributes, lutaml_register: Configuration.context.id).to_xml .force_encoding("UTF-8") .gsub("<", "<") .gsub(">", ">") @@ -178,8 +229,11 @@ def unit(units, formula, dims, norm_text, name, options) end def unitname(text, name) - name ||= unit_instance(text)&.en_name || text - Model::Units::Name.new(name: name) + name ||= unit_en_name(unit_instance(text)) || text + Model::Units::Name.new( + name: name, + lutaml_register: Configuration.context.id, + ) end def unitsymbols(formula, options) @@ -187,31 +241,44 @@ def unitsymbols(formula, options) Model::Units::Symbol.new( type: lang, content: formula.public_send(:"to_#{lang.downcase}", options), + lutaml_register: Configuration.context.id, ) end end def unitsystem(units) ret = [] - ret << Model::Units::System.new(name: "not_SI", type: "not_SI") if units.any? { |u| !u.si_system_type? } + if units.any? { |u| !u.si_system_type? } + ret << Model::Units::System.new( + name: "not_SI", + type: "not_SI", + lutaml_register: Configuration.context.id, + ) + end if units.any?(&:si_system_type?) if units.size == 1 base = units[0].downcase_system_type == "si_base" base = true if units[0].unit_name == "g" && units[0]&.prefix_name == "k" end - ret << Model::Units::System.new(name: "SI", - type: (base ? "SI_base" : "SI_derived")) + ret << Model::Units::System.new( + name: "SI", + type: (base ? "SI_base" : "SI_derived"), + lutaml_register: Configuration.context.id, + ) end ret end def dimension(norm_text) - dim_id = unit_instance(norm_text)&.dimension_url + dim_id = unit_dimension_id(unit_instance(norm_text)) return unless dim_id dim_attrs = { id: dim_id } dimid2dimensions(dim_id)&.compact&.each { |u| dimension1(u, dim_attrs) } - Model::Dimension.new(dim_attrs).to_xml.force_encoding("UTF-8") + Model::Dimension.new( + dim_attrs, + lutaml_register: Configuration.context.id, + ).to_xml.force_encoding("UTF-8") end def dimension1(dim, dims_hash) @@ -220,6 +287,7 @@ def dimension1(dim, dims_hash) dims_hash[underscore(dim_name).to_sym] = dim_klass.new( symbol: dim[:symbol], power_numerator: float_to_display(dim[:exponent]), + lutaml_register: Configuration.context.id, ) end @@ -250,23 +318,44 @@ def dimid2dimensions(normtext) def prefixes(units, options) uniq_prefixes = units.filter_map(&:prefix).uniq(&:prefix_name) uniq_prefixes.map do |prefix| - prefix_attrs = { prefix_base: prefix&.base, - prefix_power: prefix&.power, id: prefix&.id } - type_and_methods = { ASCII: :to_asciimath, unicode: :to_unicode, - LaTeX: :to_latex, HTML: :to_html } - prefix_attrs[:name] = Model::Prefixes::Name.new(content: prefix&.name) - prefix_attrs[:symbol] = type_and_methods.map do |type, method_name| - Model::Prefixes::Symbol.new( - type: type, - content: prefix&.public_send(method_name, options), - ) - end - Model::Prefix.new(prefix_attrs).to_xml.force_encoding("UTF-8").gsub( - "&", "&" - ) + prefix_xml(prefix, options) end.join("\n") end + def prefix_xml(prefix, options) + Model::Prefix.new( + prefix_attributes(prefix, options), + lutaml_register: Configuration.context.id, + ).to_xml.force_encoding("UTF-8").gsub("&", "&") + end + + def prefix_attributes(prefix, options) + { + prefix_base: prefix&.base, + prefix_power: prefix&.power, + id: prefix&.id, + name: prefix_name(prefix), + symbol: prefix_symbols(prefix, options), + } + end + + def prefix_name(prefix) + Model::Prefixes::Name.new( + content: prefix&.name, + lutaml_register: Configuration.context.id, + ) + end + + def prefix_symbols(prefix, options) + PREFIX_SYMBOL_METHODS.map do |type, method_name| + Model::Prefixes::Symbol.new( + type: type, + content: prefix&.public_send(method_name, options), + lutaml_register: Configuration.context.id, + ) + end + end + def rootunits(units) return if units.size == 1 && !units[0].prefix @@ -275,9 +364,15 @@ def rootunits(units) attributes[:prefix] = unit.prefix_name if unit.prefix unit.power_numerator && unit.power_numerator != "1" and attributes[:power_numerator] = unit.power_numerator.raw_value - Model::Units::EnumeratedRootUnit.new(attributes) + Model::Units::EnumeratedRootUnit.new( + **attributes, + lutaml_register: Configuration.context.id, + ) end - Model::Units::RootUnits.new(enumerated_root_unit: enum_root_units) + Model::Units::RootUnits.new( + enumerated_root_unit: enum_root_units, + lutaml_register: Configuration.context.id, + ) end def unit_id(text) @@ -288,7 +383,7 @@ def unit_id(text) end def format_unit_id(unit, text) - return unit.nist_id&.gsub("'", "_") if unit + return unit_nist_id(unit)&.gsub("'", "_") if unit text&.gsub("*", ".")&.gsub("^", "") end @@ -298,7 +393,10 @@ def dimension_components(dims) dim_attrs = { id: dim_id(dims) } dims.map { |u| dimension1(u, dim_attrs) } - Model::Dimension.new(dim_attrs).to_xml.force_encoding("UTF-8") + Model::Dimension.new( + **dim_attrs, + lutaml_register: Configuration.context.id, + ).to_xml.force_encoding("UTF-8") end def quantity(normtext, instance) @@ -307,10 +405,36 @@ def quantity(normtext, instance) model_quantity_xml( instance || unit.quantity_references&.first&.id, - "##{unit.dimension_url}", + "##{unit_dimension_id(unit)}", ) end + def unit_nist_id(unit) + return unless unit + return unit.nist_id if unit.respond_to?(:nist_id) + + unit.identifiers&.find { |identifier| identifier.type == "nist" }&.id + end + + def unit_en_name(unit) + return unless unit + return unit.en_name if unit.respond_to?(:en_name) + + unit.names&.find { |name| name.lang == "en" }&.value + end + + def unit_dimension_id(unit) + return unless unit + return unit.dimension_url if unit.respond_to?(:dimension_url) + + unit.dimension_reference&.id || + quantity_dimension_id(quantity_instance(unit.quantity_references&.first&.id)) + end + + def quantity_dimension_id(quantity) + quantity&.dimension_reference&.id + end + def unit_or_quantity(unit, quantity) (unit && unit.quantity_references.size == 1) || quantity_instance(quantity) @@ -321,6 +445,7 @@ def model_quantity_xml(id, url) id: id, name: quantity_name(id), dimension_url: url, + lutaml_register: Configuration.context.id, ).to_xml.force_encoding("UTF-8") end @@ -328,7 +453,7 @@ def quantity_name(id) quantity_instance(id)&.names&.filter_map do |name| next unless name.lang == "en" - Model::Quantities::Name.new(content: name.value) + Model::Quantities::Name.new(content: name.value, lutaml_register: Configuration.context.id) end end diff --git a/spec/unitsml/mathml_helper_spec.rb b/spec/unitsml/mathml_helper_spec.rb new file mode 100644 index 0000000..67c7128 --- /dev/null +++ b/spec/unitsml/mathml_helper_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Unitsml::MathmlHelper do + let(:helper_host) { Class.new { extend Unitsml::MathmlHelper } } + + describe ".mml_v4_with_content" do + it "replaces the mapped content attribute with the provided content" do + instance = helper_host.send(:mml_v4_new, :mi, value: []) + + updated = helper_host.send(:mml_v4_with_content, instance, "m") + + expect(updated.value).to eq(["m"]) + end + end +end diff --git a/spec/unitsml/unit_spec.rb b/spec/unitsml/unit_spec.rb new file mode 100644 index 0000000..2b317c2 --- /dev/null +++ b/spec/unitsml/unit_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Unitsml::Unit do + describe "#to_mathml" do + let(:mathml) { 'm' } + let(:prefix) { instance_double(Unitsml::Prefix) } + let(:unit) { described_class.new("m", nil, prefix: prefix) } + let(:parsed_unit) { instance_double(ParsedUnitSymbol, value: ["m"]) } + let(:updated_value) { instance_double(UpdatedMathmlValue) } + + before do + stub_const("ParsedUnitSymbol", Class.new) + stub_const("UpdatedMathmlValue", Class.new) + stub_const("UnitSymbol", Class.new) + + allow(unit).to receive(:unit_symbols).and_return(unit_symbol) + allow(unit).to receive(:mml_v4_from_xml).with("mi", mathml) + .and_return(parsed_unit) + allow(prefix).to receive(:to_mathml) + .with(hash_including(parent: parsed_unit)) + .and_return("m") + allow(unit).to receive(:mml_v4_with_content) + .with(parsed_unit, "mm") + .and_return(updated_value) + end + + def unit_symbol + instance_double(UnitSymbol, mathml: mathml) + end + + it "joins parsed token collections before prefix concatenation" do + expect(unit.to_mathml({})).to eq(method_name: :mi, value: updated_value) + end + end +end diff --git a/spec/unitsml/unitsdb/database_spec.rb b/spec/unitsml/unitsdb/database_spec.rb new file mode 100644 index 0000000..cfe495f --- /dev/null +++ b/spec/unitsml/unitsdb/database_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +RSpec.describe Unitsml::Unitsdb::Database do + describe ".from_db" do + context "when not running on opal" do + before do + stub_const("RUBY_ENGINE", "ruby") + allow(Unitsdb::Database) + .to receive(:from_db).and_return(:loaded_database) + end + + it "delegates to the parent database loader" do + result = described_class.from_db("/tmp/unitsdb", + context: :unitsml_ruby) + + expect(result).to eq(:loaded_database) + expect(Unitsdb::Database).to have_received(:from_db).with( + "/tmp/unitsdb", context: :unitsml_ruby + ) + end + end + + context "when running on opal" do + before { stub_const("RUBY_ENGINE", "opal") } + + it "raises a clear error when the bundled payload is missing" do + expect do + described_class.from_db("/does/not/matter", context: :unitsml_ruby) + end.to raise_error(Unitsml::Errors::OpalPayloadNotBundledError, + /not bundled/) + end + end + end + + describe Unitsml::Unitsdb, ".database" do + let(:context) { Struct.new(:id).new(:unitsml_ruby) } + + before do + described_class.instance_variable_set(:@database, nil) + allow(Unitsml::Configuration).to receive(:context).and_return(context) + end + + after do + described_class.instance_variable_set(:@database, nil) + end + + context "when running on opal and unitsdb-ruby exposes a database loader" do + before do + stub_const("RUBY_ENGINE", "opal") + allow(Unitsdb).to receive(:database).and_return(:unitsdb_database) + end + + it "uses the unitsdb-ruby loader with the UnitsML context" do + expect(described_class.database).to eq(:unitsdb_database) + expect(Unitsdb).to have_received(:database).with( + context: :unitsml_ruby, + ) + end + end + + context "when unitsdb-ruby exposes a database loader" do + before do + stub_const("RUBY_ENGINE", "ruby") + allow(Unitsdb).to receive(:database).and_return(:unitsdb_database) + end + + it "uses the unitsdb-ruby loader with the UnitsML context" do + expect(described_class.database).to eq(:unitsdb_database) + expect(Unitsdb).to have_received(:database).with( + context: :unitsml_ruby, + ) + end + end + + context "when unitsdb-ruby cannot load its packaged data directory" do + before do + stub_const("RUBY_ENGINE", "ruby") + allow(Unitsdb).to receive(:database).and_raise( + Unitsdb::Errors::DatabaseNotFoundError, + ) + allow(described_class) + .to receive(:database_path) + .and_return("/tmp/fallback-unitsdb") + allow(Unitsml::Unitsdb::Database) + .to receive(:from_db) + .and_return(:fallback_database) + end + + it "falls back to the UnitsML database wrapper with the same context" do + expect(described_class.database).to eq(:fallback_database) + expect(Unitsml::Unitsdb::Database).to have_received(:from_db).with( + "/tmp/fallback-unitsdb", + context: :unitsml_ruby, + ) + end + end + end +end diff --git a/spec/unitsml_spec.rb b/spec/unitsml_spec.rb index 733d854..054263e 100644 --- a/spec/unitsml_spec.rb +++ b/spec/unitsml_spec.rb @@ -4,4 +4,10 @@ it "has a version number" do expect(Unitsml::VERSION).not_to be_nil end + + it "parses a basic unit expression" do + formula = described_class.parse("mm") + expect(formula).to be_a(Unitsml::Formula) + expect(formula.to_latex).to eq("m\\ensuremath{\\mathrm{m}}") + end end