diff --git a/Gemfile b/Gemfile index 28e5797..2e84117 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,7 @@ source "https://rubygems.org" gemspec gem "canon" -gem "lutaml-model", github: "lutaml/lutaml-model", branch: "main" +gem "lutaml-model", github: "lutaml/lutaml-model", branch: "fix/global-context-register-lookup-fallback" gem "nokogiri" gem "rake" gem "rspec" diff --git a/lib/unitsdb.rb b/lib/unitsdb.rb index c8b43a3..f65b981 100644 --- a/lib/unitsdb.rb +++ b/lib/unitsdb.rb @@ -1,52 +1,45 @@ # frozen_string_literal: true require "lutaml/model" +require "unitsdb/config" +require "unitsdb/identifier" +require "unitsdb/localized_string" +require "unitsdb/symbol_presentations" +require "unitsdb/scale_properties" +require "unitsdb/unit_reference" +require "unitsdb/prefix_reference" +require "unitsdb/quantity_reference" +require "unitsdb/dimension_reference" +require "unitsdb/unit_system_reference" +require "unitsdb/scale_reference" +require "unitsdb/external_reference" +require "unitsdb/root_unit_reference" +require "unitsdb/si_derived_base" +require "unitsdb/dimension_details" +require "unitsdb/dimension" +require "unitsdb/prefix" +require "unitsdb/unit_system" +require "unitsdb/quantity" +require "unitsdb/scale" +require "unitsdb/unit" +require "unitsdb/dimensions" +require "unitsdb/prefixes" +require "unitsdb/quantities" +require "unitsdb/scales" +require "unitsdb/unit_systems" +require "unitsdb/units" +require "unitsdb/database" +require "unitsdb/qudt" +require "unitsdb/ucum" module Unitsdb - autoload :Cli, "unitsdb/cli" - autoload :Config, "unitsdb/config" - autoload :Commands, "unitsdb/commands" - autoload :Database, "unitsdb/database" - autoload :Dimension, "unitsdb/dimension" - autoload :DimensionDetails, "unitsdb/dimension_details" - autoload :DimensionReference, "unitsdb/dimension_reference" - autoload :Dimensions, "unitsdb/dimensions" + # Core models are eagerly loaded so type registrations are complete before + # any context or database loading happens. + unless RUBY_ENGINE == "opal" + autoload :Cli, "unitsdb/cli" + autoload :Commands, "unitsdb/commands" + end autoload :Errors, "unitsdb/errors" - autoload :ExternalReference, "unitsdb/external_reference" - autoload :Identifier, "unitsdb/identifier" - autoload :LocalizedString, "unitsdb/localized_string" - autoload :Prefix, "unitsdb/prefix" - autoload :PrefixReference, "unitsdb/prefix_reference" - autoload :Prefixes, "unitsdb/prefixes" - autoload :Quantities, "unitsdb/quantities" - autoload :Quantity, "unitsdb/quantity" - autoload :QuantityReference, "unitsdb/quantity_reference" - autoload :QudtUnit, "unitsdb/qudt" - autoload :QudtQuantityKind, "unitsdb/qudt" - autoload :QudtDimensionVector, "unitsdb/qudt" - autoload :QudtSystemOfUnits, "unitsdb/qudt" - autoload :QudtPrefix, "unitsdb/qudt" - autoload :QudtVocabularies, "unitsdb/qudt" - autoload :RootUnitReference, "unitsdb/root_unit_reference" - autoload :Scale, "unitsdb/scale" - autoload :ScaleProperties, "unitsdb/scale_properties" - autoload :ScaleReference, "unitsdb/scale_reference" - autoload :Scales, "unitsdb/scales" - autoload :SiDerivedBase, "unitsdb/si_derived_base" - autoload :SymbolPresentations, "unitsdb/symbol_presentations" - autoload :UcumBaseUnit, "unitsdb/ucum" - autoload :UcumPrefixValue, "unitsdb/ucum" - autoload :UcumPrefix, "unitsdb/ucum" - autoload :UcumUnitValueFunction, "unitsdb/ucum" - autoload :UcumUnitValue, "unitsdb/ucum" - autoload :UcumUnit, "unitsdb/ucum" - autoload :UcumFile, "unitsdb/ucum" - autoload :Unit, "unitsdb/unit" - autoload :UnitReference, "unitsdb/unit_reference" - autoload :UnitSystem, "unitsdb/unit_system" - autoload :UnitSystemReference, "unitsdb/unit_system_reference" - autoload :UnitSystems, "unitsdb/unit_systems" - autoload :Units, "unitsdb/units" autoload :Utils, "unitsdb/utils" class << self @@ -56,12 +49,19 @@ def data_dir end # Returns a pre-loaded Database instance from the bundled data - def database - @database ||= Database.from_db(data_dir) + def database(context: Config.context_id) + context_id = context.to_sym + Config.context(context_id) if context_id == Config.context_id && Config.find_context(context_id).nil? + klass = Config.resolve_type(:database, context: context_id) + databases[context_id] ||= klass.from_db(data_dir, context: context_id) end private + def databases + @databases ||= {} + end + def gem_dir @gem_dir ||= File.dirname(__dir__) end diff --git a/lib/unitsdb/config.rb b/lib/unitsdb/config.rb index 8a9f19d..97c88d9 100644 --- a/lib/unitsdb/config.rb +++ b/lib/unitsdb/config.rb @@ -2,17 +2,129 @@ module Unitsdb class Config + CONTEXT_ID = :unitsdb_v2 + class << self + def context_id + @context_id ||= CONTEXT_ID + end + + def register_model(klass, id:) + registered_models[id.to_sym] = klass + klass + end + + def registered_models + @registered_models ||= {} + end + def models @models ||= {} end def models=(user_models) - models.merge!(user_models) + normalized_models = user_models.each_with_object({}) do |(id, klass), result| + model_id = id.to_sym + result[model_id] = register_model(klass, id: model_id) + end + + models.merge!(normalized_models) end def model_for(model_name) - models[model_name] + model_id = model_name.to_sym + models[model_id] || registered_models[model_id] + end + + def register(id = context_id) + explicit_registers[id.to_sym] + end + + def populate_register(id: context_id, fallback_to: [:default], substitutions: []) + register_id = id.to_sym + context(register_id) + + model_register = Lutaml::Model::Register.new(register_id, fallback: fallback_to) + resolve_substitutions( + substitutions, + registry: build_registry, + fallback_to: fallback_to, + id: "#{register_id}_register", + ).each do |substitution| + model_register.register_global_type_substitution(**substitution) + end + + explicit_registers[register_id] = Lutaml::Model::GlobalRegister.register(model_register) + end + + def find_context(id) + Lutaml::Model::GlobalContext.context(id.to_sym) + end + + def resolve_type(type_name, context: context_id) + Lutaml::Model::GlobalContext.resolve_type(type_name, context.to_sym) + end + + def context(id = context_id, force_populate: false) + existing = find_context(id) + return existing if existing && !force_populate && populated?(id) + + populate_context(id: id) + end + + def populate_context(id: context_id, fallback_to: [:default], substitutions: []) + Lutaml::Model::GlobalContext.unregister_context(id) if find_context(id) + + opts = { registry: build_registry, fallback_to: fallback_to, id: id } + context = Lutaml::Model::GlobalContext.create_context( + substitutions: resolve_substitutions(substitutions, **opts), + **opts, + ) + mark_populated!(id) + context + end + + def resolve_substitutions(substitutions, registry:, fallback_to:, id:) + resolution_context = Lutaml::Model::TypeContext.derived( + id: "#{id}_substitution_resolution", + registry: registry, + fallback_to: fallback_to, + ) + + Array(substitutions).map do |substitution| + from_key = substitution[:from_type] || substitution[:from] + to_key = substitution[:to_type] || substitution[:to] + + { + from_type: resolve_substitution_type(from_key, resolution_context), + to_type: resolve_substitution_type(to_key, resolution_context), + } + end + end + + def resolve_substitution_type(value, resolution_context) + return value if value.is_a?(Class) + + Lutaml::Model::TypeResolver.resolve(value, resolution_context) + end + + def build_registry + registry = Lutaml::Model::TypeRegistry.new + registered_models.each { |model_id, klass| registry.register(model_id, klass) } + registry + end + + def populated?(context_id) + @populated_for&.[](context_id.to_sym) + end + + def mark_populated!(context_id) + @populated_for ||= {} + @populated_for[context_id.to_sym] = true + end + + def explicit_registers + @explicit_registers ||= {} end end end diff --git a/lib/unitsdb/database.rb b/lib/unitsdb/database.rb index a73510d..8218736 100644 --- a/lib/unitsdb/database.rb +++ b/lib/unitsdb/database.rb @@ -4,6 +4,15 @@ module Unitsdb class Database < Lutaml::Model::Serializable # model Config.model_for(:units) + DATABASE_FILES = { + "prefixes" => "prefixes.yaml", + "dimensions" => "dimensions.yaml", + "units" => "units.yaml", + "quantities" => "quantities.yaml", + "unit_systems" => "unit_systems.yaml", + }.freeze + SUPPORTED_SCHEMA_VERSION = "2.0.0" + attribute :schema_version, :string attribute :version, :string attribute :units, Unit, collection: true @@ -290,25 +299,21 @@ def validate_references invalid_refs end - def self.from_db(dir_path) - # If dir_path is a relative path, make it relative to the current working directory - db_path = dir_path - puts "Database directory path: #{db_path}" + def self.from_db(dir_path, context: Unitsdb::Config.context_id) + context_id = context.to_sym + if context_id == Unitsdb::Config.context_id && + Unitsdb::Config.find_context(context_id).nil? + Unitsdb::Config.context(context_id) + end - # Check if the directory exists + db_path = File.expand_path(dir_path.to_s) unless Dir.exist?(db_path) raise Errors::DatabaseNotFoundError, "Database directory not found: #{db_path}" end - # Define required files - required_files = %w[prefixes.yaml dimensions.yaml units.yaml - quantities.yaml unit_systems.yaml] - yaml_files = required_files.map { |file| File.join(dir_path, file) } - - # Check if all required files exist - missing_files = required_files.reject do |file| - File.exist?(File.join(dir_path, file)) + missing_files = DATABASE_FILES.values.reject do |filename| + File.exist?(File.join(db_path, filename)) end if missing_files.any? @@ -316,99 +321,92 @@ def self.from_db(dir_path) "Missing required database files: #{missing_files.join(', ')}" end - # Ensure we have path properly joined with filenames - prefixes_yaml = yaml_files[0] - dimensions_yaml = yaml_files[1] - units_yaml = yaml_files[2] - quantities_yaml = yaml_files[3] - unit_systems_yaml = yaml_files[4] - - # Debug paths - if ENV["DEBUG"] - puts "[UnitsDB] Loading YAML files from directory: #{dir_path}" - puts " - #{prefixes_yaml}" - puts " - #{dimensions_yaml}" - puts " - #{units_yaml}" - puts " - #{quantities_yaml}" - puts " - #{unit_systems_yaml}" + documents = load_database_documents(db_path) + schema_version = validate_schema_versions!(documents) + combined_hash = build_database_hash(documents, schema_version) + + Lutaml::Model::GlobalContext.with_context(context_id) do + if Unitsdb::Config.register(context_id) + from_hash(combined_hash, register: context_id) + else + from_hash(combined_hash) + end end + end - # Load YAML files with better error handling - begin - prefixes_hash = YAML.safe_load_file(prefixes_yaml) - dimensions_hash = YAML.safe_load_file(dimensions_yaml) - units_hash = YAML.safe_load_file(units_yaml) - quantities_hash = YAML.safe_load_file(quantities_yaml) - unit_systems_hash = YAML.safe_load_file(unit_systems_yaml) - rescue Errno::ENOENT => e - raise Errors::DatabaseFileNotFoundError, - "Failed to read database file: #{e.message}" - rescue Psych::SyntaxError => e + def self.load_database_documents(db_path) + puts "[UnitsDB] Loading YAML files from directory: #{db_path}" if ENV["UNITSDB_DEBUG"] + DATABASE_FILES.transform_values do |filename| + puts " - #{File.join(db_path, filename)}" if ENV["UNITSDB_DEBUG"] + load_database_yaml(File.join(db_path, filename), filename) + end + end + + def self.load_database_yaml(path, filename) + document = YAML.safe_load_file(path) + + unless document.is_a?(Hash) raise Errors::DatabaseFileInvalidError, - "Invalid YAML in database file: #{e.message}" - rescue StandardError => e - raise Errors::DatabaseLoadError, "Error loading database: #{e.message}" + "Invalid YAML structure in #{filename}: expected a mapping" end - # Verify all files have schema_version field - missing_schema = [] - missing_schema << "prefixes.yaml" unless prefixes_hash.key?("schema_version") - missing_schema << "dimensions.yaml" unless dimensions_hash.key?("schema_version") - missing_schema << "units.yaml" unless units_hash.key?("schema_version") - missing_schema << "quantities.yaml" unless quantities_hash.key?("schema_version") - missing_schema << "unit_systems.yaml" unless unit_systems_hash.key?("schema_version") + document + rescue Errno::ENOENT => e + raise Errors::DatabaseFileNotFoundError, + "Failed to read database file: #{e.message}" + rescue Psych::SyntaxError => e + raise Errors::DatabaseFileInvalidError, + "Invalid YAML in database file: #{e.message}" + rescue Errors::DatabaseError + raise + rescue StandardError => e + raise Errors::DatabaseLoadError, + "Error loading database file #{filename}: #{e.message}" + end + private_class_method :load_database_documents, :load_database_yaml - if missing_schema.any? + def self.validate_schema_versions!(documents) + versions = DATABASE_FILES.each_with_object({}) do |(collection_key, filename), result| + document = documents.fetch(collection_key) + result[filename] = document.fetch("schema_version") + rescue KeyError raise Errors::DatabaseFileInvalidError, - "Missing schema_version in files: #{missing_schema.join(', ')}" + "Missing schema_version in #{filename}" end - # Extract versions from each file - prefixes_version = prefixes_hash["schema_version"] - dimensions_version = dimensions_hash["schema_version"] - units_version = units_hash["schema_version"] - quantities_version = quantities_hash["schema_version"] - unit_systems_version = unit_systems_hash["schema_version"] - - # Check if all versions match - versions = [ - prefixes_version, - dimensions_version, - units_version, - quantities_version, - unit_systems_version, - ] - - unless versions.uniq.size == 1 - version_info = { - "prefixes.yaml" => prefixes_version, - "dimensions.yaml" => dimensions_version, - "units.yaml" => units_version, - "quantities.yaml" => quantities_version, - "unit_systems.yaml" => unit_systems_version, - } + unless versions.values.uniq.size == 1 raise Errors::VersionMismatchError, - "Version mismatch in database files: #{version_info.inspect}" + "Version mismatch in database files: #{versions.inspect}" end - # Check if the version is supported - version = versions.first - unless version == "2.0.0" + version = versions.values.first + unless version == SUPPORTED_SCHEMA_VERSION raise Errors::UnsupportedVersionError, - "Unsupported database version: #{version}. Only version 2.0.0 is supported." + "Unsupported database version: #{version}. Only version #{SUPPORTED_SCHEMA_VERSION} is supported." end - combined_yaml = { - "schema_version" => prefixes_version, - "prefixes" => prefixes_hash["prefixes"], - "dimensions" => dimensions_hash["dimensions"], - "units" => units_hash["units"], - "quantities" => quantities_hash["quantities"], - "unit_systems" => unit_systems_hash["unit_systems"], - }.to_yaml + version + end + + def self.build_database_hash(documents, schema_version) + { + "schema_version" => schema_version, + }.merge( + DATABASE_FILES.keys.to_h do |collection_key| + document = documents.fetch(collection_key) + [collection_key, fetch_collection!(document, collection_key)] + end, + ) + end - from_yaml(combined_yaml) + def self.fetch_collection!(document, collection_key) + document.fetch(collection_key) + rescue KeyError + raise Errors::DatabaseFileInvalidError, + "Missing #{collection_key} collection in #{DATABASE_FILES.fetch(collection_key)}" end + private_class_method :validate_schema_versions!, :build_database_hash, + :fetch_collection! private @@ -616,8 +614,7 @@ def check_root_unit_references(registry, invalid_refs) end end - def validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs, -file_type) + def validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs, file_type) # Handle references that are objects with id and type (could be a hash or an object) if ref_id.respond_to?(:id) && ref_id.respond_to?(:type) id = ref_id.id @@ -697,4 +694,6 @@ def validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs, end end end + + Config.register_model(Database, id: :database) end diff --git a/lib/unitsdb/dimension.rb b/lib/unitsdb/dimension.rb index 2ebd5bf..b4171b1 100644 --- a/lib/unitsdb/dimension.rb +++ b/lib/unitsdb/dimension.rb @@ -43,4 +43,6 @@ class Dimension < Lutaml::Model::Serializable attribute :names, LocalizedString, collection: true attribute :references, ExternalReference, collection: true end + + Config.register_model(Dimension, id: :dimension) end diff --git a/lib/unitsdb/dimension_details.rb b/lib/unitsdb/dimension_details.rb index 82b7c38..9fbee5f 100644 --- a/lib/unitsdb/dimension_details.rb +++ b/lib/unitsdb/dimension_details.rb @@ -16,4 +16,6 @@ class DimensionDetails < Lutaml::Model::Serializable attribute :symbol, :string attribute :symbols, SymbolPresentations, collection: true end + + Config.register_model(DimensionDetails, id: :dimension_details) end diff --git a/lib/unitsdb/dimension_reference.rb b/lib/unitsdb/dimension_reference.rb index 89a39ee..1af9273 100644 --- a/lib/unitsdb/dimension_reference.rb +++ b/lib/unitsdb/dimension_reference.rb @@ -5,4 +5,6 @@ class DimensionReference < Identifier attribute :id, :string attribute :type, :string end + + Config.register_model(DimensionReference, id: :dimension_reference) end diff --git a/lib/unitsdb/dimensions.rb b/lib/unitsdb/dimensions.rb index a5aa840..1840dae 100644 --- a/lib/unitsdb/dimensions.rb +++ b/lib/unitsdb/dimensions.rb @@ -8,4 +8,6 @@ class Dimensions < Lutaml::Model::Serializable attribute :version, :string attribute :dimensions, Dimension, collection: true end + + Config.register_model(Dimensions, id: :dimensions) end diff --git a/lib/unitsdb/external_reference.rb b/lib/unitsdb/external_reference.rb index 6f98626..2845e83 100644 --- a/lib/unitsdb/external_reference.rb +++ b/lib/unitsdb/external_reference.rb @@ -11,4 +11,6 @@ class ExternalReference < Identifier attribute :type, :string, values: %w[normative informative] attribute :authority, :string end + + Config.register_model(ExternalReference, id: :external_reference) end diff --git a/lib/unitsdb/identifier.rb b/lib/unitsdb/identifier.rb index 2ae2a9f..e8254d5 100644 --- a/lib/unitsdb/identifier.rb +++ b/lib/unitsdb/identifier.rb @@ -5,4 +5,6 @@ class Identifier < Lutaml::Model::Serializable attribute :id, :string attribute :type, :string end + + Config.register_model(Identifier, id: :identifier) end diff --git a/lib/unitsdb/localized_string.rb b/lib/unitsdb/localized_string.rb index f18d49f..aad2ca1 100644 --- a/lib/unitsdb/localized_string.rb +++ b/lib/unitsdb/localized_string.rb @@ -14,4 +14,6 @@ def downcase value&.downcase end end + + Config.register_model(LocalizedString, id: :localized_string) end diff --git a/lib/unitsdb/prefix.rb b/lib/unitsdb/prefix.rb index ca78713..b8899ea 100644 --- a/lib/unitsdb/prefix.rb +++ b/lib/unitsdb/prefix.rb @@ -23,4 +23,6 @@ class Prefix < Lutaml::Model::Serializable attribute :power, :integer attribute :references, ExternalReference, collection: true end + + Config.register_model(Prefix, id: :prefix) end diff --git a/lib/unitsdb/prefix_reference.rb b/lib/unitsdb/prefix_reference.rb index d757045..fd36ee1 100644 --- a/lib/unitsdb/prefix_reference.rb +++ b/lib/unitsdb/prefix_reference.rb @@ -5,4 +5,6 @@ class PrefixReference < Identifier attribute :id, :string attribute :type, :string end + + Config.register_model(PrefixReference, id: :prefix_reference) end diff --git a/lib/unitsdb/prefixes.rb b/lib/unitsdb/prefixes.rb index 40ee559..9a54767 100644 --- a/lib/unitsdb/prefixes.rb +++ b/lib/unitsdb/prefixes.rb @@ -19,4 +19,6 @@ class Prefixes < Lutaml::Model::Serializable attribute :version, :string attribute :prefixes, Prefix, collection: true end + + Config.register_model(Prefixes, id: :prefixes) end diff --git a/lib/unitsdb/quantities.rb b/lib/unitsdb/quantities.rb index 5a33f83..84ecf71 100644 --- a/lib/unitsdb/quantities.rb +++ b/lib/unitsdb/quantities.rb @@ -7,4 +7,6 @@ class Quantities < Lutaml::Model::Serializable attribute :version, :string attribute :quantities, Quantity, collection: true end + + Config.register_model(Quantities, id: :quantities) end diff --git a/lib/unitsdb/quantity.rb b/lib/unitsdb/quantity.rb index 3738a55..dc28134 100644 --- a/lib/unitsdb/quantity.rb +++ b/lib/unitsdb/quantity.rb @@ -12,4 +12,6 @@ class Quantity < Lutaml::Model::Serializable attribute :dimension_reference, DimensionReference attribute :references, ExternalReference, collection: true end + + Config.register_model(Quantity, id: :quantity) end diff --git a/lib/unitsdb/quantity_reference.rb b/lib/unitsdb/quantity_reference.rb index 6942b53..5623dcf 100644 --- a/lib/unitsdb/quantity_reference.rb +++ b/lib/unitsdb/quantity_reference.rb @@ -7,4 +7,6 @@ class QuantityReference < Identifier attribute :id, :string attribute :type, :string end + + Config.register_model(QuantityReference, id: :quantity_reference) end diff --git a/lib/unitsdb/qudt.rb b/lib/unitsdb/qudt.rb index 74a76ad..bbeb6aa 100644 --- a/lib/unitsdb/qudt.rb +++ b/lib/unitsdb/qudt.rb @@ -18,6 +18,7 @@ def identifier "qudt:unit:#{uri}" end end + Config.register_model(QudtUnit, id: :qudt_unit) # QUDT QuantityKind from quantitykinds vocabulary # Example: http://qudt.org/vocab/quantitykind/Length @@ -33,6 +34,7 @@ def identifier "qudt:quantitykind:#{uri}" end end + Config.register_model(QudtQuantityKind, id: :qudt_quantity_kind) # QUDT DimensionVector from dimensionvectors vocabulary # Example: http://qudt.org/vocab/dimensionvector/A0E0L1I0M0H0T0D0 @@ -52,6 +54,7 @@ def identifier "qudt:dimensionvector:#{uri}" end end + Config.register_model(QudtDimensionVector, id: :qudt_dimension_vector) # QUDT SystemOfUnits from sou vocabulary # Example: http://qudt.org/vocab/sou/SI @@ -65,6 +68,7 @@ def identifier "qudt:sou:#{uri}" end end + Config.register_model(QudtSystemOfUnits, id: :qudt_system_of_units) # QUDT Prefix from prefixes vocabulary # Example: http://qudt.org/vocab/prefix/Kilo @@ -83,6 +87,7 @@ def identifier "qudt:prefix:#{uri}" end end + Config.register_model(QudtPrefix, id: :qudt_prefix) # Container for all QUDT vocabularies class QudtVocabularies diff --git a/lib/unitsdb/root_unit_reference.rb b/lib/unitsdb/root_unit_reference.rb index b09ed8f..96f2a4c 100644 --- a/lib/unitsdb/root_unit_reference.rb +++ b/lib/unitsdb/root_unit_reference.rb @@ -8,4 +8,6 @@ class RootUnitReference < Lutaml::Model::Serializable attribute :unit_reference, UnitReference attribute :prefix_reference, PrefixReference end + + Config.register_model(RootUnitReference, id: :root_unit_reference) end diff --git a/lib/unitsdb/scale.rb b/lib/unitsdb/scale.rb index 117ea06..5fe2053 100644 --- a/lib/unitsdb/scale.rb +++ b/lib/unitsdb/scale.rb @@ -10,4 +10,6 @@ class Scale < Lutaml::Model::Serializable attribute :short, :string attribute :properties, ScaleProperties end + + Config.register_model(Scale, id: :scale) end diff --git a/lib/unitsdb/scale_properties.rb b/lib/unitsdb/scale_properties.rb index 11d6ca9..1780475 100644 --- a/lib/unitsdb/scale_properties.rb +++ b/lib/unitsdb/scale_properties.rb @@ -9,4 +9,6 @@ class ScaleProperties < Lutaml::Model::Serializable attribute :interval, :boolean attribute :ratio, :boolean end + + Config.register_model(ScaleProperties, id: :scale_properties) end diff --git a/lib/unitsdb/scale_reference.rb b/lib/unitsdb/scale_reference.rb index c3fb19a..e70e789 100644 --- a/lib/unitsdb/scale_reference.rb +++ b/lib/unitsdb/scale_reference.rb @@ -5,4 +5,6 @@ class ScaleReference < Identifier attribute :id, :string attribute :type, :string end + + Config.register_model(ScaleReference, id: :scale_reference) end diff --git a/lib/unitsdb/scales.rb b/lib/unitsdb/scales.rb index 716cf8c..b2ae397 100644 --- a/lib/unitsdb/scales.rb +++ b/lib/unitsdb/scales.rb @@ -7,4 +7,6 @@ class Scales < Lutaml::Model::Serializable attribute :version, :string attribute :scales, Scale, collection: true end + + Config.register_model(Scales, id: :scales) end diff --git a/lib/unitsdb/si_derived_base.rb b/lib/unitsdb/si_derived_base.rb index 7bf45ad..0903bfe 100644 --- a/lib/unitsdb/si_derived_base.rb +++ b/lib/unitsdb/si_derived_base.rb @@ -14,4 +14,6 @@ module Unitsdb class SiDerivedBase < RootUnitReference # model Config.model_for(:si_derived_base) end + + Config.register_model(SiDerivedBase, id: :si_derived_base) end diff --git a/lib/unitsdb/symbol_presentations.rb b/lib/unitsdb/symbol_presentations.rb index 52d4f61..19e5bed 100644 --- a/lib/unitsdb/symbol_presentations.rb +++ b/lib/unitsdb/symbol_presentations.rb @@ -11,4 +11,6 @@ class SymbolPresentations < Lutaml::Model::Serializable attribute :mathml, :string attribute :unicode, :string end + + Config.register_model(SymbolPresentations, id: :symbol_presentations) end diff --git a/lib/unitsdb/ucum.rb b/lib/unitsdb/ucum.rb index 6d3fd5d..3c0a85c 100644 --- a/lib/unitsdb/ucum.rb +++ b/lib/unitsdb/ucum.rb @@ -28,6 +28,7 @@ def identifier "ucum:base-unit:code:#{code_sensitive}" end end + Config.register_model(UcumBaseUnit, id: :ucum_base_unit) # # yotta @@ -46,6 +47,7 @@ class UcumPrefixValue < Lutaml::Model::Serializable map_content to: :content end end + Config.register_model(UcumPrefixValue, id: :ucum_prefix_value) class UcumPrefix < Lutaml::Model::Serializable attribute :code_sensitive, :string @@ -67,6 +69,7 @@ def identifier "ucum:prefix:code:#{code_sensitive}" end end + Config.register_model(UcumPrefix, id: :ucum_prefix) # # the number ten for arbitrary powers @@ -114,6 +117,7 @@ class UcumUnitValueFunction < Lutaml::Model::Serializable map_attribute "Unit", to: :unit_sensitive end end + Config.register_model(UcumUnitValueFunction, id: :ucum_unit_value_function) class UcumUnitValue < Lutaml::Model::Serializable attribute :unit_sensitive, :string @@ -131,6 +135,7 @@ class UcumUnitValue < Lutaml::Model::Serializable map_content to: :content end end + Config.register_model(UcumUnitValue, id: :ucum_unit_value) class UcumUnit < Lutaml::Model::Serializable attribute :code_sensitive, :string @@ -165,6 +170,7 @@ def identifier "ucum:unit:#{k}:code:#{code_sensitive}" end end + Config.register_model(UcumUnit, id: :ucum_unit) class UcumNamespace < Lutaml::Xml::Namespace uri "http://unitsofmeasure.org/ucum-essence" @@ -199,4 +205,5 @@ class UcumFile < Lutaml::Model::Serializable # No adapter registration needed end + Config.register_model(UcumFile, id: :ucum_file) end diff --git a/lib/unitsdb/unit.rb b/lib/unitsdb/unit.rb index 867f88a..958515b 100644 --- a/lib/unitsdb/unit.rb +++ b/lib/unitsdb/unit.rb @@ -50,4 +50,6 @@ class Unit < Lutaml::Model::Serializable attribute :references, ExternalReference, collection: true attribute :scale_reference, ScaleReference end + + Config.register_model(Unit, id: :unit) end diff --git a/lib/unitsdb/unit_reference.rb b/lib/unitsdb/unit_reference.rb index a14ad19..99d8d56 100644 --- a/lib/unitsdb/unit_reference.rb +++ b/lib/unitsdb/unit_reference.rb @@ -5,4 +5,6 @@ class UnitReference < Identifier attribute :id, :string attribute :type, :string end + + Config.register_model(UnitReference, id: :unit_reference) end diff --git a/lib/unitsdb/unit_system.rb b/lib/unitsdb/unit_system.rb index 320d2cf..d6d9ff7 100644 --- a/lib/unitsdb/unit_system.rb +++ b/lib/unitsdb/unit_system.rb @@ -10,4 +10,6 @@ class UnitSystem < Lutaml::Model::Serializable attribute :acceptable, :boolean attribute :references, ExternalReference, collection: true end + + Config.register_model(UnitSystem, id: :unit_system) end diff --git a/lib/unitsdb/unit_system_reference.rb b/lib/unitsdb/unit_system_reference.rb index eb51bd7..0f7a9d6 100644 --- a/lib/unitsdb/unit_system_reference.rb +++ b/lib/unitsdb/unit_system_reference.rb @@ -5,4 +5,6 @@ class UnitSystemReference < Identifier attribute :id, :string attribute :type, :string end + + Config.register_model(UnitSystemReference, id: :unit_system_reference) end diff --git a/lib/unitsdb/unit_systems.rb b/lib/unitsdb/unit_systems.rb index ca22b9e..5000acf 100644 --- a/lib/unitsdb/unit_systems.rb +++ b/lib/unitsdb/unit_systems.rb @@ -8,4 +8,6 @@ class UnitSystems < Lutaml::Model::Serializable attribute :version, :string attribute :unit_systems, UnitSystem, collection: true end + + Config.register_model(UnitSystems, id: :unit_systems) end diff --git a/lib/unitsdb/units.rb b/lib/unitsdb/units.rb index a8b609e..24f969d 100644 --- a/lib/unitsdb/units.rb +++ b/lib/unitsdb/units.rb @@ -8,4 +8,6 @@ class Units < Lutaml::Model::Serializable attribute :version, :string attribute :units, Unit, collection: true end + + Config.register_model(Units, id: :units) end diff --git a/spec/unitsdb/bundled_data_spec.rb b/spec/unitsdb/bundled_data_spec.rb index b0f2ac0..136a7d3 100644 --- a/spec/unitsdb/bundled_data_spec.rb +++ b/spec/unitsdb/bundled_data_spec.rb @@ -33,76 +33,33 @@ end describe ".database" do - it "returns a pre-loaded Database instance" do - db = described_class.database - expect(db).to be_a(Unitsdb::Database) + around do |example| + described_class.instance_variable_set(:@databases, nil) + Lutaml::Model::GlobalContext.reset! + example.run + ensure + described_class.instance_variable_set(:@databases, nil) + Lutaml::Model::GlobalContext.reset! end - it "loads all entity collections" do + it "boots successfully on first access and caches the bundled database" do db = described_class.database - expect(db.units).to be_a(Array) - expect(db.prefixes).to be_a(Array) - expect(db.dimensions).to be_a(Array) - expect(db.quantities).to be_a(Array) - expect(db.unit_systems).to be_a(Array) - end - it "has a valid schema version" do - db = described_class.database + expect(db).to be_a(Unitsdb::Database) expect(db.schema_version).to eq("2.0.0") + expect(described_class.database).to equal(db) end - it "populates units with known entities" do - db = described_class.database - unit_ids = db.units.flat_map { |u| u.identifiers.map(&:id) }.compact.uniq - expect(unit_ids).to include("NISTu1") # meter - end - - it "populates prefixes with known entities" do - db = described_class.database - prefix_ids = db.prefixes.flat_map do |p| - p.identifiers.map(&:id) - end.compact.uniq - expect(prefix_ids).to include("NISTp10_3") # kilo - end - - it "populates dimensions with known entities" do + it "loads known entities across each bundled collection" do db = described_class.database - dimension_ids = db.dimensions.flat_map do |d| - d.identifiers.map(&:id) - end.compact.uniq - expect(dimension_ids).to include("NISTd1") # length - end - - it "populates quantities with known entities" do - db = described_class.database - quantity_ids = db.quantities.flat_map do |q| - q.identifiers.map(&:id) - end.compact.uniq - expect(quantity_ids).to include("NISTq1") # length - end - it "populates unit_systems with known entities" do - db = described_class.database - system_ids = db.unit_systems.flat_map do |s| - s.identifiers.map(&:id) - end.compact.uniq - expect(system_ids).to include("SI_base") # SI - end - - it "has non-empty collections" do - db = described_class.database - expect(db.units).not_to be_empty - expect(db.prefixes).not_to be_empty - expect(db.dimensions).not_to be_empty - expect(db.quantities).not_to be_empty - expect(db.unit_systems).not_to be_empty - end - - it "caches the database instance" do - db1 = described_class.database - db2 = described_class.database - expect(db1.object_id).to eq(db2.object_id) + aggregate_failures do + expect(db.get_by_id(id: "NISTu1")).to be_a(Unitsdb::Unit) + expect(db.get_by_id(id: "NISTp10_3")).to be_a(Unitsdb::Prefix) + expect(db.get_by_id(id: "NISTd1")).to be_a(Unitsdb::Dimension) + expect(db.get_by_id(id: "NISTq1")).to be_a(Unitsdb::Quantity) + expect(db.get_by_id(id: "SI_base")).to be_a(Unitsdb::UnitSystem) + end end end diff --git a/spec/unitsdb/config_spec.rb b/spec/unitsdb/config_spec.rb new file mode 100644 index 0000000..f0da24b --- /dev/null +++ b/spec/unitsdb/config_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Unitsdb::Config do + let(:database_path) { File.join(__dir__, "../../data") } + + around do |example| + original_models = described_class.registered_models.dup + original_registers = described_class.explicit_registers.dup + original_legacy_models = described_class.models.dup + original_populated_for = described_class.instance_variable_get(:@populated_for)&.dup + + Lutaml::Model::GlobalContext.reset! + Unitsdb.instance_variable_set(:@databases, nil) + example.run + ensure + %i[custom_unitsdb custom_unitsdb_with_register].each do |id| + Lutaml::Model::GlobalRegister.unregister(id) + rescue StandardError + nil + end + described_class.instance_variable_set(:@registered_models, original_models) + described_class.instance_variable_set(:@explicit_registers, original_registers) + described_class.instance_variable_set(:@models, original_legacy_models) + described_class.instance_variable_set(:@populated_for, original_populated_for) + Lutaml::Model::GlobalContext.reset! + Unitsdb.instance_variable_set(:@databases, nil) + end + + it "builds a custom context without implicitly using it as a register" do + stub_const("CustomContextUnit", Class.new(Unitsdb::Unit)) + + described_class.register_model(Unitsdb::Unit, id: :unit) + described_class.register_model(CustomContextUnit, id: :custom_unit) + + described_class.populate_context( + id: :custom_unitsdb, + fallback_to: [described_class.context_id], + substitutions: [ + { from_type: :unit, to_type: :custom_unit }, + ], + ) + + db = Unitsdb::Database.from_db(database_path, context: :custom_unitsdb) + context = described_class.context(:custom_unitsdb) + + expect(context).not_to be_nil + expect(context.substitutions.length).to eq(1) + expect(described_class.register(:custom_unitsdb)).to be_nil + expect(db.units.first).to be_a(Unitsdb::Unit) + expect(db.units.first).not_to be_a(CustomContextUnit) + expect(db.get_by_id(id: "NISTu1")).to be_a(Unitsdb::Unit) + end + + it "uses a custom context as a register after explicit register population" do + stub_const("CustomContextUnitWithRegister", Class.new(Unitsdb::Unit)) + + described_class.register_model(Unitsdb::Unit, id: :unit) + described_class.register_model(CustomContextUnitWithRegister, + id: :custom_unit_with_register) + + described_class.populate_context( + id: :custom_unitsdb_with_register, + fallback_to: [described_class.context_id], + substitutions: [ + { from_type: :unit, to_type: :custom_unit_with_register }, + ], + ) + + described_class.populate_register( + id: :custom_unitsdb_with_register, + fallback_to: [described_class.context_id], + ) + + db = Unitsdb::Database.from_db(database_path, + context: :custom_unitsdb_with_register) + + expect(described_class.register(:custom_unitsdb_with_register)).not_to be_nil + expect(db.units.first).to be_a(CustomContextUnitWithRegister) + end + + describe "compatibility" do + it "uses eagerly loaded core models without a bootstrap manifest" do + expect(described_class.const_defined?(:CORE_MODEL_CONSTANTS, false)).to be(false) + expect(Unitsdb.respond_to?(:load_core_models!)).to be(false) + expect(described_class.send(:build_registry)).to be_a(Lutaml::Model::TypeRegistry) + expect(described_class.registered_models[:database]).to be(Unitsdb::Database) + expect(described_class.context).not_to be_nil + expect(described_class.resolve_type(:database)).to be(Unitsdb::Database) + end + + it "keeps the legacy model registration interface available on Config" do + stub_const("LegacyConfiguredUnit", Class.new(Unitsdb::Unit)) + + described_class.models = { unit: LegacyConfiguredUnit } + + expect(described_class.model_for(:unit)).to be(LegacyConfiguredUnit) + expect(described_class.registered_models[:unit]).to be(LegacyConfiguredUnit) + end + end + + describe ".context" do + it "rebuilds an existing context when forced" do + initial_context = described_class.context + rebuilt_context = described_class.context(force_populate: true) + + expect(rebuilt_context).not_to equal(initial_context) + expect(described_class.find_context(described_class.context_id)).to equal(rebuilt_context) + end + end + + describe ".database integration" do + it "does not auto-create third-party contexts" do + expect(described_class).not_to receive(:context).with(:unitsml_ruby) + allow(described_class).to receive(:resolve_type) + .with(:database, context: :unitsml_ruby) + .and_return(Unitsdb::Database) + allow(Unitsdb::Database).to receive(:from_db).and_return(:foreign_context_db) + + expect(Unitsdb.database(context: :unitsml_ruby)).to eq(:foreign_context_db) + end + + it "loads bundled data through a pre-populated custom context" do + stub_const("CustomDatabaseUnit", Class.new(Unitsdb::Unit)) + + described_class.register_model(CustomDatabaseUnit, id: :custom_database_unit) + described_class.populate_context( + id: :custom_unitsdb_database, + fallback_to: [described_class.context_id], + substitutions: [ + { from_type: :unit, to_type: :custom_database_unit }, + ], + ) + described_class.populate_register( + id: :custom_unitsdb_database, + fallback_to: [described_class.context_id], + ) + + db = Unitsdb.database(context: :custom_unitsdb_database) + + expect(db).to be_a(Unitsdb::Database) + expect(db.units.first).to be_a(CustomDatabaseUnit) + end + end +end diff --git a/spec/unitsdb/database_spec.rb b/spec/unitsdb/database_spec.rb index 103da59..07d74b6 100644 --- a/spec/unitsdb/database_spec.rb +++ b/spec/unitsdb/database_spec.rb @@ -1,7 +1,22 @@ # frozen_string_literal: true +require "fileutils" +require "tmpdir" + RSpec.describe Unitsdb::Database do - dir_path = File.join(__dir__, "../../data/") + let(:dir_path) { File.expand_path("../../data", __dir__) } + let(:database_files) do + %w[prefixes.yaml dimensions.yaml units.yaml quantities.yaml unit_systems.yaml] + end + + around do |example| + Lutaml::Model::GlobalContext.reset! + Unitsdb.instance_variable_set(:@databases, nil) + example.run + ensure + Lutaml::Model::GlobalContext.reset! + Unitsdb.instance_variable_set(:@databases, nil) + end it "parses the full unitsdb database" do parsed = described_class.from_db(dir_path) @@ -32,4 +47,135 @@ # puts raw_string expect(generated).to be_yaml_equivalent_to(combined_yaml) end + + it "does not write to stdout during a normal load" do + original_debug = ENV.fetch("UNITSDB_DEBUG", nil) + ENV.delete("UNITSDB_DEBUG") + + output = capture_output { described_class.from_db(dir_path) } + + expect(output[:output]).to eq("") + ensure + ENV["UNITSDB_DEBUG"] = original_debug + end + + it "preserves an externally managed non-default context during load" do + external_context = Lutaml::Model::GlobalContext.create_context( + id: :externally_managed_unitsdb, + registry: Unitsdb::Config.send(:build_registry), + fallback_to: [:default], + substitutions: [], + ) + + db = described_class.from_db(dir_path, context: :externally_managed_unitsdb) + + expect(db).to be_a(described_class) + expect(Unitsdb::Config.find_context(:externally_managed_unitsdb)).to equal(external_context) + end + + it "raises a helpful error when the database directory is empty" do + Dir.mktmpdir do |tmpdir| + expect do + described_class.from_db(tmpdir) + end.to raise_error( + Unitsdb::Errors::DatabaseFileNotFoundError, + /Missing required database files:/, + ) + end + end + + it "raises a helpful error when a database YAML file is not a mapping" do + Dir.mktmpdir do |tmpdir| + copy_database_files(tmpdir) + File.write(File.join(tmpdir, "units.yaml"), ["invalid"].to_yaml) + + expect do + described_class.from_db(tmpdir) + end.to raise_error( + Unitsdb::Errors::DatabaseFileInvalidError, + /Invalid YAML structure in units\.yaml: expected a mapping/, + ) + end + end + + it "raises a helpful error when a collection key is missing" do + Dir.mktmpdir do |tmpdir| + copy_database_files(tmpdir) + + units_file = File.join(tmpdir, "units.yaml") + units_hash = YAML.safe_load_file(units_file) + units_hash.delete("units") + File.write(units_file, units_hash.to_yaml) + + expect do + described_class.from_db(tmpdir) + end.to raise_error( + Unitsdb::Errors::DatabaseFileInvalidError, + /Missing units collection in units\.yaml/, + ) + end + end + + it "raises a helpful error when schema_version is missing" do + Dir.mktmpdir do |tmpdir| + copy_database_files(tmpdir) + update_database_file(tmpdir, "units.yaml") do |units_hash| + units_hash.delete("schema_version") + end + + expect do + described_class.from_db(tmpdir) + end.to raise_error( + Unitsdb::Errors::DatabaseFileInvalidError, + /Missing schema_version in units\.yaml/, + ) + end + end + + it "raises a helpful error when schema versions do not match" do + Dir.mktmpdir do |tmpdir| + copy_database_files(tmpdir) + update_database_file(tmpdir, "quantities.yaml") do |quantities_hash| + quantities_hash["schema_version"] = "2.0.1" + end + + expect do + described_class.from_db(tmpdir) + end.to raise_error( + Unitsdb::Errors::VersionMismatchError, + /Version mismatch in database files: .*"quantities\.yaml"\s*=>\s*"2\.0\.1"/, + ) + end + end + + it "raises a helpful error when schema version is unsupported" do + Dir.mktmpdir do |tmpdir| + copy_database_files(tmpdir) + database_files.each do |filename| + update_database_file(tmpdir, filename) do |document| + document["schema_version"] = "3.0.0" + end + end + + expect do + described_class.from_db(tmpdir) + end.to raise_error( + Unitsdb::Errors::UnsupportedVersionError, + /Unsupported database version: 3\.0\.0\. Only version 2\.0\.0 is supported\./, + ) + end + end + + def copy_database_files(target_dir) + database_files.each do |filename| + FileUtils.cp(File.join(dir_path, filename), File.join(target_dir, filename)) + end + end + + def update_database_file(tmpdir, filename) + file_path = File.join(tmpdir, filename) + document = YAML.safe_load_file(file_path) + yield document + File.write(file_path, document.to_yaml) + end end diff --git a/spec/unitsdb/version_compatibility_spec.rb b/spec/unitsdb/version_compatibility_spec.rb deleted file mode 100644 index bac61d8..0000000 --- a/spec/unitsdb/version_compatibility_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe "UnitsDB 2.0.0 Features" do - let(:database_path) { File.join(__dir__, "../../data/") } - let(:db) { Unitsdb::Database.from_db(database_path) } - - describe "version validation" do - it "verifies database is version 2.0.0" do - expect(db.schema_version).to eq("2.0.0") - end - end - - describe "multilingual support" do - it "handles multilingual names in both units and quantities" do - # Test units multilingual support - meter = db.find_by_type(id: "NISTu1", type: "units") - expect(meter.names).to be_an(Array) - expect(meter.names.first).to respond_to(:value) - expect(meter.names.first).to respond_to(:lang) - - english_names = meter.names.select { |n| n.lang == "en" }.map(&:value) - french_names = meter.names.select { |n| n.lang == "fr" }.map(&:value) - expect(english_names).to include("metre") - expect(french_names).to include("mètre") - - # Test quantities multilingual support - length = db.find_by_type(id: "NISTq1", type: "quantities") - expect(length.names).to be_an(Array) - expect(length.names.first).to respond_to(:value) - english_names = length.names.select { |n| n.lang == "en" }.map(&:value) - expect(english_names).to include("length") - end - end - - describe "multiple symbol formats" do - it "handles multiple symbol formats for both units and prefixes" do - # Test unit symbols - meter = db.find_by_type(id: "NISTu1", type: "units") - expect(meter.symbols).to be_an(Array) - symbol = meter.symbols.first - expect(symbol).to respond_to(:ascii) - expect(symbol).to respond_to(:html) - expect(symbol).to respond_to(:latex) - - # Test prefix symbols - kilo = db.find_by_type(id: "NISTp10_3", type: "prefixes") - expect(kilo.symbols).to be_an(Array) - symbol = kilo.symbols.first - expect(symbol).to respond_to(:ascii) - expect(symbol.ascii).to eq("k") - end - end - - describe "find_by_symbol functionality" do - it "finds entities by symbol" do - # Find units by symbol - units = db.find_by_symbol("m", "units") - expect(units).to be_an(Array) - expect(units.map(&:short)).to include("meter") - - # Find prefixes by symbol - prefixes = db.find_by_symbol("k", "prefixes") - expect(prefixes).to be_an(Array) - expect(prefixes.map(&:short)).to include("kilo") - end - end -end