diff --git a/.gitignore b/.gitignore index 5c249f1..f59d3d8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ .rspec_status Gemfile.lock + +# Local-only refactor audit notes — never commit +TODO* diff --git a/lib/unitsdb.rb b/lib/unitsdb.rb index f65b981..0fa4504 100644 --- a/lib/unitsdb.rb +++ b/lib/unitsdb.rb @@ -1,46 +1,124 @@ # 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 - # Core models are eagerly loaded so type registrations are complete before - # any context or database loading happens. + # All model constants are autoloaded from their respective files. + # Config.build_registry triggers `eager_load_models!` so the type + # registry is complete before iteration — otherwise autoload would + # leave unreferenced models (Scale, Qudt*, Ucum*) unregistered. + + autoload :Config, "unitsdb/config" + autoload :Errors, "unitsdb/errors" + autoload :Utils, "unitsdb/utils" + autoload :Database, "unitsdb/database" + + # Leaf models + autoload :Identifier, "unitsdb/identifier" + autoload :LocalizedString, "unitsdb/localized_string" + autoload :SymbolPresentations, "unitsdb/symbol_presentations" + autoload :ScaleProperties, "unitsdb/scale_properties" + + # Reference models + autoload :UnitReference, "unitsdb/unit_reference" + autoload :PrefixReference, "unitsdb/prefix_reference" + autoload :QuantityReference, "unitsdb/quantity_reference" + autoload :DimensionReference, "unitsdb/dimension_reference" + autoload :UnitSystemReference, "unitsdb/unit_system_reference" + autoload :ScaleReference, "unitsdb/scale_reference" + autoload :ExternalReference, "unitsdb/external_reference" + autoload :RootUnitReference, "unitsdb/root_unit_reference" + autoload :SiDerivedBase, "unitsdb/si_derived_base" + autoload :DimensionDetails, "unitsdb/dimension_details" + + # Entity models + autoload :Dimension, "unitsdb/dimension" + autoload :Prefix, "unitsdb/prefix" + autoload :UnitSystem, "unitsdb/unit_system" + autoload :Quantity, "unitsdb/quantity" + autoload :Scale, "unitsdb/scale" + autoload :Unit, "unitsdb/unit" + + # Collection container models + autoload :Dimensions, "unitsdb/dimensions" + autoload :Prefixes, "unitsdb/prefixes" + autoload :Quantities, "unitsdb/quantities" + autoload :Scales, "unitsdb/scales" + autoload :UnitSystems, "unitsdb/unit_systems" + autoload :Units, "unitsdb/units" + + # QUDT vocabulary models — all defined in unitsdb/qudt.rb + autoload :QudtUnit, "unitsdb/qudt" + autoload :QudtQuantityKind, "unitsdb/qudt" + autoload :QudtDimensionVector, "unitsdb/qudt" + autoload :QudtSystemOfUnits, "unitsdb/qudt" + autoload :QudtPrefix, "unitsdb/qudt" + autoload :QudtVocabularies, "unitsdb/qudt" + + # UCUM XML models — all defined in unitsdb/ucum.rb + 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 :UcumNamespace, "unitsdb/ucum" + autoload :UcumFile, "unitsdb/ucum" + + # CLI and Commands pull in Thor, which doesn't run under Opal. unless RUBY_ENGINE == "opal" autoload :Cli, "unitsdb/cli" autoload :Commands, "unitsdb/commands" end - autoload :Errors, "unitsdb/errors" - autoload :Utils, "unitsdb/utils" + + # Constants eagerly loaded so Lutaml::Model type registrations are + # complete before Config.build_registry iterates them. Listed + # explicitly so unreferenced models (Scale, Qudt*, Ucum*) still + # register themselves via their file-bottom `Config.register_model` + # calls. + MODELS = %i[ + Identifier + LocalizedString + SymbolPresentations + ScaleProperties + UnitReference + PrefixReference + QuantityReference + DimensionReference + UnitSystemReference + ScaleReference + ExternalReference + RootUnitReference + SiDerivedBase + DimensionDetails + Dimension + Prefix + UnitSystem + Quantity + Scale + Unit + Dimensions + Prefixes + Quantities + Scales + UnitSystems + Units + Database + QudtUnit + QudtQuantityKind + QudtDimensionVector + QudtSystemOfUnits + QudtPrefix + QudtVocabularies + UcumBaseUnit + UcumPrefixValue + UcumPrefix + UcumUnitValueFunction + UcumUnitValue + UcumUnit + UcumNamespace + UcumFile + ].freeze class << self # Returns the path to the bundled data directory containing YAML files @@ -51,13 +129,42 @@ def data_dir # Returns a pre-loaded Database instance from the bundled data 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? + Config.ensure_default_context! if context_id == Config.context_id klass = Config.resolve_type(:database, context: context_id) databases[context_id] ||= klass.from_db(data_dir, context: context_id) end + # Drop the memoized Database instances so the next `database` call + # reloads from disk. Specs use this between examples. + def reset_database_cache! + @databases = nil + end + + # Force every autoloaded model to load AND register itself with + # Config. The per-file `Config.register_model(Class, id: :foo)` + # calls at file-bottom run the first time a model loads, but + # `Config.capture_state` / `restore_state` can wipe those entries + # between tests. Re-registering here makes the registry + # self-healing across snapshot/restore cycles. + def eager_load_models! + MODELS.each do |sym| + klass = const_get(sym) + next unless klass.is_a?(Class) && klass < ::Lutaml::Model::Serializable + + Config.register_model(klass, id: model_id_for(klass)) + end + end + private + def model_id_for(klass) + klass.name.split("::").last + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .downcase + .to_sym + end + def databases @databases ||= {} end diff --git a/lib/unitsdb/cli.rb b/lib/unitsdb/cli.rb index 4aa3461..34d2168 100644 --- a/lib/unitsdb/cli.rb +++ b/lib/unitsdb/cli.rb @@ -1,20 +1,9 @@ # frozen_string_literal: true require "thor" -require "fileutils" module Unitsdb - class Cli < Thor - # Enable --trace globally for all subcommands - # When enabled, Thor shows full backtraces on error - class_option :trace, type: :boolean, default: false, - desc: "Show full backtrace on error" - - # Fix Thor deprecation warning - def self.exit_on_failure? - true - end - + class Cli < Unitsdb::Commands::Thor desc "ucum SUBCOMMAND", "UCUM-related commands" subcommand "ucum", Commands::UcumCommand @@ -41,7 +30,7 @@ def self.exit_on_failure? desc: "Path to UnitsDB database (required)" def search(query) - run_command(Commands::Search, :run, query) + run_command(Commands::Search, options, query, method: :run) end desc "get ID", "Get detailed information about an entity by ID" @@ -52,7 +41,7 @@ def search(query) option :database, type: :string, required: true, aliases: "-d", desc: "Path to UnitsDB database (required)" def get(id) - run_command(Commands::Get, :get, id) + run_command(Commands::Get, options, id, method: :get) end desc "check_si", @@ -71,7 +60,7 @@ def get(id) desc: "Path to UnitsDB database (required)" def check_si - run_command(Commands::CheckSiCommand, :run) + run_command(Commands::CheckSiCommand, options) end desc "release", "Create release files (unified YAML and/or ZIP archive)" @@ -84,32 +73,7 @@ def check_si option :database, type: :string, required: true, aliases: "-d", desc: "Path to UnitsDB database (required)" def release - run_command(Commands::Release, :run) - end - - private - - def run_command(command_class, method, *args) - command = command_class.new(options) - command.send(method, *args) - rescue Unitsdb::Errors::CLIRuntimeError => e - handle_cli_error(e) - rescue StandardError => e - handle_error(e) - end - - def handle_cli_error(error) - raise error if debugging? - - warn "Error: #{error.message}" - exit 1 - end - - def handle_error(error) - raise error if debugging? - - warn "Error: #{error.message}" - exit 1 + run_command(Commands::Release, options) end end end diff --git a/lib/unitsdb/commands.rb b/lib/unitsdb/commands.rb index bb91b5e..4760cfb 100644 --- a/lib/unitsdb/commands.rb +++ b/lib/unitsdb/commands.rb @@ -2,10 +2,12 @@ module Unitsdb module Commands + autoload :Thor, "unitsdb/commands/thor" autoload :ModifyCommand, "unitsdb/commands/_modify" autoload :Base, "unitsdb/commands/base" autoload :CheckSi, "unitsdb/commands/check_si" autoload :CheckSiCommand, "unitsdb/commands/check_si" + autoload :EntityPresenter, "unitsdb/commands/entity_presenter" autoload :Get, "unitsdb/commands/get" autoload :Normalize, "unitsdb/commands/normalize" autoload :Qudt, "unitsdb/commands/qudt" diff --git a/lib/unitsdb/commands/_modify.rb b/lib/unitsdb/commands/_modify.rb index 90e71bc..3ceb552 100644 --- a/lib/unitsdb/commands/_modify.rb +++ b/lib/unitsdb/commands/_modify.rb @@ -4,11 +4,7 @@ module Unitsdb module Commands - class ModifyCommand < Thor - # Inherit trace option from parent CLI - class_option :trace, type: :boolean, default: false, - desc: "Show full backtrace on error" - + class ModifyCommand < Unitsdb::Commands::Thor desc "normalize INPUT OUTPUT", "Normalize a YAML file or all YAML files with --all" method_option :sort, type: :string, @@ -23,35 +19,6 @@ class ModifyCommand < Thor def normalize(input = nil, output = nil) run_command(Normalize, options, input, output) - rescue Unitsdb::Errors::CLIRuntimeError => e - handle_cli_error(e) - rescue StandardError => e - handle_error(e) - end - - private - - def run_command(command_class, options, *args) - command = command_class.new(options) - command.run(*args) - end - - def handle_cli_error(error) - if options[:trace] - raise error - else - warn "Error: #{error.message}" - exit 1 - end - end - - def handle_error(error) - if options[:trace] - raise error - else - warn "Error: #{error.message}" - exit 1 - end end end end diff --git a/lib/unitsdb/commands/check_si/si_formatter.rb b/lib/unitsdb/commands/check_si/si_formatter.rb index 348a5e3..7632866 100644 --- a/lib/unitsdb/commands/check_si/si_formatter.rb +++ b/lib/unitsdb/commands/check_si/si_formatter.rb @@ -238,7 +238,7 @@ def display_db_results(entity_type, matches, missing_refs, unmatched_db) matches.each do |match| db_entity = match[:db_entity] entity_id = match[:entity_id] || db_entity.short - entity_name = db_entity.respond_to?(:names) ? db_entity.names&.first : "unnamed" + entity_name = db_entity.names.first || "unnamed" si_suffix = SiTtlParser.extract_identifying_suffix(match[:ttl_uri]) ttl_label = match[:ttl_entity] ? (match[:ttl_entity][:label] || match[:ttl_entity][:name]) : "Unknown" @@ -279,7 +279,7 @@ def display_db_results(entity_type, matches, missing_refs, unmatched_db) # Get match description if available entity_id = match[:db_entity].short match_pair_key = "#{entity_id}:#{ttl_entities.first[:uri]}" - match_details = Unitsdb::Commands::CheckSi::SiMatcher.instance_variable_get(:@match_details)&.dig(match_pair_key) + match_details = Unitsdb::Commands::CheckSi::SiMatcher.match_details&.dig(match_pair_key) match_desc = match_details[:match_desc] if match_details && match_details[:match_desc] # Symbol matches and partial matches should always be potential matches @@ -301,7 +301,7 @@ def display_db_results(entity_type, matches, missing_refs, unmatched_db) exact_matches.each do |match| db_entity = match[:db_entity] entity_id = match[:entity_id] || db_entity.short - entity_name = db_entity.respond_to?(:names) ? db_entity.names&.first : "unnamed" + entity_name = db_entity.names.first || "unnamed" # Handle multiple TTL entities in a single row ttl_entities = match[:ttl_entities] @@ -347,7 +347,7 @@ def display_db_results(entity_type, matches, missing_refs, unmatched_db) # Get match details for this match match_pair_key = "#{db_entity.short}:#{ttl_entities.first[:uri]}" - match_details = Unitsdb::Commands::CheckSi::SiMatcher.instance_variable_get(:@match_details)&.dig(match_pair_key) + match_details = Unitsdb::Commands::CheckSi::SiMatcher.match_details&.dig(match_pair_key) # Format match info match_info = "" @@ -377,7 +377,7 @@ def display_db_results(entity_type, matches, missing_refs, unmatched_db) potential_matches.each do |match| db_entity = match[:db_entity] entity_id = match[:entity_id] || db_entity.short - entity_name = db_entity.respond_to?(:names) ? db_entity.names&.first : "unnamed" + entity_name = db_entity.names.first || "unnamed" # Handle multiple TTL entities in a single row ttl_entities = match[:ttl_entities] @@ -423,7 +423,7 @@ def display_db_results(entity_type, matches, missing_refs, unmatched_db) # Get match details match_pair_key = "#{db_entity.short}:#{ttl_entities.first[:uri]}" - match_details = Unitsdb::Commands::CheckSi::SiMatcher.instance_variable_get(:@match_details)&.dig(match_pair_key) + match_details = Unitsdb::Commands::CheckSi::SiMatcher.match_details&.dig(match_pair_key) # Format match info match_info = "" diff --git a/lib/unitsdb/commands/check_si/si_matcher.rb b/lib/unitsdb/commands/check_si/si_matcher.rb index 6a59052..ca49512 100644 --- a/lib/unitsdb/commands/check_si/si_matcher.rb +++ b/lib/unitsdb/commands/check_si/si_matcher.rb @@ -3,10 +3,18 @@ module Unitsdb module Commands module CheckSi - # Matcher for SI entities and UnitsDB entities + # Matcher for SI digital-framework entities and UnitsDB entities. + # All iteration is typed — entities expose `identifiers`, `names`, + # `short`, `references`, and (for units/prefixes) `symbols` as + # declared Lutaml attributes, so we read them directly. module SiMatcher SI_AUTHORITY = "si-digital-framework" - @match_details = {} + SYMBOL_ENTITY_TYPES = %w[units prefixes].freeze + + class << self + attr_accessor :match_details + end + self.match_details = {} module_function @@ -15,14 +23,12 @@ def match_ttl_to_db(entity_type, ttl_entities, db_entities) matches = [] missing_matches = [] matched_ttl_uris = [] - processed_pairs = {} # Track processed entity-ttl pairs to avoid duplicates - entity_matches = {} # Track matches by entity ID + processed_pairs = {} + entity_matches = {} - # First pass: find direct references db_entities.each do |entity| - next unless entity.respond_to?(:references) && entity.references - - entity.references.each do |ref| + references = entity.references || [] + references.each do |ref| next unless ref.authority == SI_AUTHORITY matched_ttl_uris << ref.uri @@ -42,42 +48,33 @@ def match_ttl_to_db(entity_type, ttl_entities, db_entities) end end - # Second pass: find matching entities ttl_entities.each do |ttl_entity| next if matched_ttl_uris.include?(ttl_entity[:uri]) - matching_entities = find_matching_entities(entity_type, ttl_entity, - db_entities) + matching_entities = find_matching_entities(entity_type, ttl_entity, db_entities) next if matching_entities.empty? matched_ttl_uris << ttl_entity[:uri] matching_entities.each do |entity| entity_id = entity.short - entity_name = format_entity_name(entity) - - # Create a unique key for this entity-ttl pair to avoid duplicates pair_key = "#{entity_id}:#{ttl_entity[:uri]}" next if processed_pairs[pair_key] processed_pairs[pair_key] = true - # Get detailed match information - match_result = match_entity_names?(entity_type, entity, - ttl_entity) + match_result = match_entity_names?(entity_type, entity, ttl_entity) next unless match_result[:match] - # Save match details for later use - @match_details[pair_key] = match_result + match_details[pair_key] = match_result - # Check if already has reference - has_reference = entity.references&.any? do |ref| + has_reference = (entity.references || []).any? do |ref| ref.uri == ttl_entity[:uri] && ref.authority == SI_AUTHORITY end match_data = { entity_id: entity_id, - entity_name: entity_name, + entity_name: format_entity_name(entity), si_uri: ttl_entity[:uri], si_name: ttl_entity[:name], si_label: ttl_entity[:label], @@ -92,35 +89,24 @@ def match_ttl_to_db(entity_type, ttl_entities, db_entities) if has_reference matches << match_data else - # Group by entity_id for multiple SI matches entity_matches[entity_id] ||= [] entity_matches[entity_id] << { uri: ttl_entity[:uri], name: ttl_entity[:name], label: ttl_entity[:label], } - - # Add first occurrence of this entity to missing_matches - missing_matches << match_data unless missing_matches.any? do |m| - m[:entity_id] == entity_id - end + missing_matches << match_data unless missing_matches.any? { |m| m[:entity_id] == entity_id } end end end - # Update missing_matches to include multiple SI entities missing_matches.each do |match| - entity_id = match[:entity_id] - si_matches = entity_matches[entity_id] + si_matches = entity_matches[match[:entity_id]] + next unless si_matches && si_matches.size > 1 - # If entity matches multiple SI entities, record them - if si_matches && si_matches.size > 1 - match[:multiple_si] = - si_matches - end + match[:multiple_si] = si_matches end - # Find unmatched TTL entities unmatched_ttl = ttl_entities.reject do |entity| matched_ttl_uris.include?(entity[:uri]) || entity[:uri].end_with?("/units/") || @@ -136,78 +122,49 @@ def match_db_to_ttl(entity_type, ttl_entities, db_entities) matches = [] missing_refs = [] matched_db_ids = [] - processed_db_ids = {} # Track processed entities + processed_db_ids = {} - # Map from NIST IDs to display names for original output compatibility - nist_id_to_display = {} - - # Build mappings for each entity type - db_entities.each do |entity| - next unless entity.respond_to?(:identifiers) && entity.identifiers&.first&.id&.start_with?("NIST") - - nist_id = entity.identifiers.first.id - - # For quantities and prefixes, we want to show the "short" field - nist_id_to_display[nist_id] = entity.short if %w[quantities - prefixes].include?(entity_type) && entity.respond_to?(:short) - end + nist_id_to_display = build_nist_id_to_display(entity_type, db_entities) db_entities.each do |db_entity| entity_id = find_entity_id(db_entity) + display_id = nist_id_to_display[entity_id] || entity_id - # For display purposes - use original display names - display_id = entity_id - - # Apply the NIST ID mapping if available - display_id = nist_id_to_display[entity_id] if entity_id.start_with?("NIST") && nist_id_to_display[entity_id] - - # Skip if we've already processed this entity next if processed_db_ids[entity_id] processed_db_ids[entity_id] = true - has_reference = false - # Check for existing SI references - if db_entity.respond_to?(:references) && db_entity.references - db_entity.references.each do |ref| - next unless ref.authority == SI_AUTHORITY - - has_reference = true - # Find the matching TTL entity for display - ttl_entity = ttl_entities.find { |e| e[:uri] == ref.uri } + has_reference = false + (db_entity.references || []).each do |ref| + next unless ref.authority == SI_AUTHORITY - matches << { - entity_id: display_id, - db_entity: db_entity, - ttl_uri: ref.uri, - ttl_entity: ttl_entity, - } - end + has_reference = true + ttl_entity = ttl_entities.find { |e| e[:uri] == ref.uri } + matches << { + entity_id: display_id, + db_entity: db_entity, + ttl_uri: ref.uri, + ttl_entity: ttl_entity, + } end - # If already has reference, continue to next entity if has_reference matched_db_ids << entity_id next end - # Find matching TTL entities matching_ttl = [] match_types = {} ttl_entities.each do |ttl_entity| - match_result = match_entity_names?(entity_type, db_entity, - ttl_entity) + match_result = match_entity_names?(entity_type, db_entity, ttl_entity) next unless match_result[:match] matching_ttl << ttl_entity match_types[ttl_entity[:uri]] = match_result[:match_type] - - # Save detailed match info - @match_details["#{entity_id}:#{ttl_entity[:uri]}"] = match_result + match_details["#{entity_id}:#{ttl_entity[:uri]}"] = match_result end - # If found matches, add to missing_refs next if matching_ttl.empty? matched_db_ids << entity_id @@ -219,7 +176,6 @@ def match_db_to_ttl(entity_type, ttl_entities, db_entities) } end - # Find unmatched db entities unmatched_db = db_entities.reject do |entity| matched_db_ids.include?(find_entity_id(entity)) end @@ -227,260 +183,214 @@ def match_db_to_ttl(entity_type, ttl_entities, db_entities) [matches, missing_refs, unmatched_db] end - # Find entity ID + # UnitsDB top-level entities are identified by their first + # Identifier's id; if none is present, fall back to short. def find_entity_id(entity) - return entity.id if entity.respond_to?(:id) && entity.id - return entity.identifiers.first.id if entity.respond_to?(:identifiers) && !entity.identifiers.empty? && - entity.identifiers.first.respond_to?(:id) + identifier = entity.identifiers.first + return identifier.id if identifier&.id entity.short end - # Format entity name correctly + # First localized name (LocalizedString instance) or nil. def format_entity_name(entity) - return nil unless entity.respond_to?(:names) && entity.names&.first - entity.names.first - - # # Special handling for sidereal names - use comma format - # if name.include?("sidereal") - # if name.start_with?("sidereal ") - # # For names that already start with "sidereal " - strip it - # base_name = name.gsub("sidereal ", "") - # return "#{base_name}, sidereal" - # elsif name.end_with?(" sidereal") - # # For names that already have comma format but missing comma - # parts = name.split - # return "#{parts.first}, #{parts.last}" - # end - # end - - # # Handle other special cases - # return name if name == "year (365 days)" - - # # Default to the original name end - # Find matching entities for a TTL entity def find_matching_entities(entity_type, ttl_entity, db_entities) - case entity_type - when "units" - find_matching_units(ttl_entity, db_entities) - when "quantities" - find_matching_quantities(ttl_entity, db_entities) - when "prefixes" - find_matching_prefixes(ttl_entity, db_entities) - else - [] - end + finder = MATCHERS[entity_type] + return [] unless finder + + finder.call(ttl_entity, db_entities) end - # Find exact matches for units + # ---- Per-entity-type matchers (open for extension: add to + # MATCHERS to support a new type) ---- + def find_matching_units(ttl_unit, units) - matching_units = [] + units.select do |unit| + short_matches?(unit.short, ttl_unit) || + name_matches?(unit.names, ttl_unit) || + symbol_matches?(unit.symbols, ttl_unit) + end.uniq + end - units.each do |unit| - # Match by short - if unit.short&.downcase == ttl_unit[:name]&.downcase || - unit.short&.downcase == ttl_unit[:label]&.downcase - matching_units << unit - next - end + def find_matching_quantities(ttl_quantity, quantities) + quantities.select do |quantity| + short_matches_any?(quantity.short, ttl_quantity, %i[name label alt_label]) || + name_matches_any?(quantity.names, ttl_quantity, %i[name label alt_label]) + end.uniq + end - # Match by name - if unit.respond_to?(:names) && unit.names&.any? do |name| - name.downcase == ttl_unit[:name]&.downcase || - name.downcase == ttl_unit[:label]&.downcase - end - matching_units << unit - next - end + def find_matching_prefixes(ttl_prefix, prefixes) + prefixes.select do |prefix| + short_matches?(prefix.short, ttl_prefix) || + name_matches?(prefix.names, ttl_prefix) || + prefix_symbol_matches?(prefix.symbols, ttl_prefix) + end.uniq + end - # Match by symbol - next unless ttl_unit[:symbol] && unit.respond_to?(:symbols) && unit.symbols&.any? do |sym| - sym.respond_to?(:ascii) && sym.ascii && sym.ascii.downcase == ttl_unit[:symbol].downcase - end + MATCHERS = { + "units" => method(:find_matching_units), + "quantities" => method(:find_matching_quantities), + "prefixes" => method(:find_matching_prefixes), + }.freeze - matching_units << unit - end + # ---- Generic match primitives ---- - matching_units.uniq + def short_matches?(short, ttl_entity) + target = ttl_entity[:name]&.downcase + target_label = ttl_entity[:label]&.downcase + short && [target, target_label].include?(short.downcase) end - # Find exact matches for quantities - def find_matching_quantities(ttl_quantity, quantities) - matching_quantities = [] - - quantities.each do |quantity| - # Match by short - if quantity.short&.downcase == ttl_quantity[:name]&.downcase || - quantity.short&.downcase == ttl_quantity[:label]&.downcase || - quantity.short&.downcase == ttl_quantity[:alt_label]&.downcase - matching_quantities << quantity - next - end + def short_matches_any?(short, ttl_entity, keys) + targets = keys.map { |k| ttl_entity[k]&.downcase } + short && targets.include?(short.downcase) + end - # Match by name - next unless quantity.respond_to?(:names) && quantity.names&.any? do |name| - name.downcase == ttl_quantity[:name]&.downcase || - name.downcase == ttl_quantity[:label]&.downcase || - name.downcase == ttl_quantity[:alt_label]&.downcase - end + def name_matches?(names, ttl_entity) + targets = [ttl_entity[:name]&.downcase, ttl_entity[:label]&.downcase].compact + names.any? { |n| targets.include?(n.value&.downcase) } + end - matching_quantities << quantity - end + def name_matches_any?(names, ttl_entity, keys) + targets = keys.filter_map { |k| ttl_entity[k]&.downcase } + names.any? { |n| targets.include?(n.value&.downcase) } + end + + def symbol_matches?(symbols, ttl_entity) + ttl_symbol = ttl_entity[:symbol] + return false unless ttl_symbol - matching_quantities.uniq + needle = ttl_symbol.downcase + symbols.any? { |s| s.ascii.to_s.downcase == needle } end - # Find exact matches for prefixes - def find_matching_prefixes(ttl_prefix, prefixes) - matching_prefixes = [] + # Prefixes in 2.0 carry a `symbols` collection, just like Units. + alias prefix_symbol_matches? symbol_matches? - prefixes.each do |prefix| - # Match by short - if prefix.short&.downcase == ttl_prefix[:name]&.downcase || - prefix.short&.downcase == ttl_prefix[:label]&.downcase - matching_prefixes << prefix - next - end + # ---- Detailed match (returns a hash with match metadata) ---- - # Match by name - if prefix.respond_to?(:names) && prefix.names&.any? do |name| - name.downcase == ttl_prefix[:name]&.downcase || - name.downcase == ttl_prefix[:label]&.downcase - end - matching_prefixes << prefix - next - end + def match_entity_names?(entity_type, db_entity, ttl_entity) + matcher = DetailedMatcher.new(db_entity, ttl_entity, entity_type) + matcher.call + end - # Match by symbol - next unless ttl_prefix[:symbol] && prefix.respond_to?(:symbol) && prefix.symbol && - prefix.symbol.respond_to?(:ascii) && prefix.symbol.ascii && - prefix.symbol.ascii.downcase == ttl_prefix[:symbol].downcase + # Encapsulates the per-entity detailed match strategies. + class DetailedMatcher + def initialize(db_entity, ttl_entity, entity_type) + @db_entity = db_entity + @ttl = ttl_entity + @entity_type = entity_type + end - matching_prefixes << prefix + def call + short_to_name || short_to_label || name_to_name || + name_to_label || name_to_alt_label || sidereal_demotion || + symbol_potential || NO_MATCH end - matching_prefixes.uniq - end + NO_MATCH = { match: false }.freeze - # Match entity names with detailed type information - def match_entity_names?(entity_type, db_entity, ttl_entity) - match_details = { match: false } + private - # Match by short name - EXACT match - if db_entity.short && db_entity.short.downcase == ttl_entity[:name].downcase - match_details = { - match: true, - exact: true, - match_type: "Exact match", - match_desc: "short_to_name", - details: "UnitsDB short '#{db_entity.short}' matches SI name '#{ttl_entity[:name]}'", - } - # Match by short to label - elsif db_entity.short && ttl_entity[:label] && db_entity.short.downcase == ttl_entity[:label].downcase - match_details = { + def short_to_name + return unless @db_entity.short&.downcase == @ttl[:name]&.downcase + + exact_match("short_to_name", + "UnitsDB short '#{@db_entity.short}' matches SI name '#{@ttl[:name]}'") + end + + def short_to_label + return unless @db_entity.short && @ttl[:label] && + @db_entity.short.downcase == @ttl[:label].downcase + + exact_match("short_to_label", + "UnitsDB short '#{@db_entity.short}' matches SI label '#{@ttl[:label]}'") + end + + def name_to_name + db_name = find_name_match(@ttl[:name]) + return unless db_name + + exact_match("name_to_name", + "UnitsDB name '#{db_name}' matches SI name '#{@ttl[:name]}'") + end + + def name_to_label + return unless @ttl[:label] + + db_name = find_name_match(@ttl[:label]) + return unless db_name + + exact_match("name_to_label", + "UnitsDB name '#{db_name}' matches SI label '#{@ttl[:label]}'") + end + + def name_to_alt_label + return unless @ttl[:alt_label] + + db_name = find_name_match(@ttl[:alt_label]) + return unless db_name + + exact_match("name_to_alt_label", + "UnitsDB name '#{db_name}' matches SI alt_label '#{@ttl[:alt_label]}'") + end + + # A `sidereal_*` short counts as a partial match unless the + # TTL name/label acknowledges the sidereal form. + def sidereal_demotion + prior = short_to_name || short_to_label + return unless prior && prior[:exact] + return unless @db_entity.short&.include?("sidereal_") + return if @ttl[:name]&.include?("sidereal") || @ttl[:label]&.include?("sidereal") + + potential_match("partial_match", + "UnitsDB '#{@db_entity.short}' partially matches SI '#{@ttl[:name]}'") + end + + def symbol_potential + return unless SYMBOL_ENTITY_TYPES.include?(@entity_type) + return unless @ttl[:symbol] + + needle = @ttl[:symbol].downcase + match = @db_entity.symbols.find { |s| s.ascii.to_s.downcase == needle } + return unless match + + potential_match("symbol_match", + "UnitsDB symbol '#{match.ascii}' matches SI symbol '#{@ttl[:symbol]}'") + end + + def find_name_match(ttl_value) + return unless ttl_value + + needle = ttl_value.downcase + @db_entity.names.find { |n| n.value&.downcase == needle } + end + + def exact_match(desc, details) + { match: true, exact: true, match_type: "Exact match", - match_desc: "short_to_label", - details: "UnitsDB short '#{db_entity.short}' matches SI label '#{ttl_entity[:label]}'", + match_desc: desc, + details: details, } - # Match by names - EXACT match - elsif db_entity.respond_to?(:names) && db_entity.names - # Match by TTL name - db_name_match = db_entity.names.find do |name| - name.downcase == ttl_entity[:name].downcase - end - if db_name_match - match_details = { - match: true, - exact: true, - match_type: "Exact match", - match_desc: "name_to_name", - details: "UnitsDB name '#{db_name_match}' matches SI name '#{ttl_entity[:name]}'", - } - # Match by TTL label - elsif ttl_entity[:label] - db_name_match = db_entity.names.find do |name| - name.downcase == ttl_entity[:label].downcase - end - if db_name_match - match_details = { - match: true, - exact: true, - match_type: "Exact match", - match_desc: "name_to_label", - details: "UnitsDB name '#{db_name_match}' matches SI label '#{ttl_entity[:label]}'", - } - end - end - - # Match by TTL alt_label - if !match_details[:match] && ttl_entity[:alt_label] - db_name_match = db_entity.names.find do |name| - name.downcase == ttl_entity[:alt_label].downcase - end - if db_name_match - match_details = { - match: true, - exact: true, - match_type: "Exact match", - match_desc: "name_to_alt_label", - details: "UnitsDB name '#{db_name_match}' matches SI alt_label '#{ttl_entity[:alt_label]}'", - } - end - end end - # Special validation for "sidereal_" units - if match_details[:match] && match_details[:exact] && db_entity.short&.include?("sidereal_") && - !(ttl_entity[:name]&.include?("sidereal") || ttl_entity[:label]&.include?("sidereal")) - match_details = { + def potential_match(desc, details) + { match: true, exact: false, match_type: "Potential match", - match_desc: "partial_match", - details: "UnitsDB '#{db_entity.short}' partially matches SI '#{ttl_entity[:name]}'", + match_desc: desc, + details: details, } end - - # Match by symbol if available (units and prefixes) - POTENTIAL match - if !match_details[:match] && %w[units - prefixes].include?(entity_type) && ttl_entity[:symbol] - if entity_type == "units" && db_entity.respond_to?(:symbols) && db_entity.symbols - matching_symbol = db_entity.symbols.find do |sym| - sym.respond_to?(:ascii) && sym.ascii && sym.ascii.downcase == ttl_entity[:symbol].downcase - end - - if matching_symbol - match_details = { - match: true, - exact: false, - match_type: "Potential match", - match_desc: "symbol_match", - details: "UnitsDB symbol '#{matching_symbol.ascii}' matches SI symbol '#{ttl_entity[:symbol]}'", - } - end - elsif entity_type == "prefixes" && db_entity.respond_to?(:symbol) && db_entity.symbol - if db_entity.symbol.respond_to?(:ascii) && - db_entity.symbol.ascii && - db_entity.symbol.ascii.downcase == ttl_entity[:symbol].downcase - - match_details = { - match: true, - exact: false, - match_type: "Potential match", - match_desc: "symbol_match", - details: "UnitsDB symbol '#{db_entity.symbol.ascii}' matches SI symbol '#{ttl_entity[:symbol]}'", - } - end - end - end - - match_details end + + private_constant :DetailedMatcher, :MATCHERS end end end diff --git a/lib/unitsdb/commands/check_si/si_updater.rb b/lib/unitsdb/commands/check_si/si_updater.rb index 36c5807..ef6ed64 100644 --- a/lib/unitsdb/commands/check_si/si_updater.rb +++ b/lib/unitsdb/commands/check_si/si_updater.rb @@ -13,24 +13,9 @@ module SiUpdater module_function # Update references in YAML file (TTL → DB direction) - def update_references(entity_type, missing_matches, db_entities, + def update_references(entity_type, missing_matches, _db_entities, output_file, include_potential = false) - # Use the database objects to access the data directly - original_yaml_file = db_entities.first.send(:yaml_file) if db_entities&.first.respond_to?( - :yaml_file, true - ) - - # If we can't get the path from the database object, use the output file path as a fallback - if original_yaml_file.nil? || !File.exist?(original_yaml_file) - puts "Warning: Could not determine original YAML file path. Using output file as template." - original_yaml_file = output_file - - # Create an empty template if output file doesn't exist - unless File.exist?(original_yaml_file) - FileUtils.mkdir_p(File.dirname(original_yaml_file)) - File.write(original_yaml_file, { entity_type => [] }.to_yaml) - end - end + original_yaml_file = resolve_yaml_file(output_file, entity_type) # Load the original YAML file yaml_content = File.read(original_yaml_file) @@ -111,23 +96,7 @@ def update_references(entity_type, missing_matches, db_entities, # Update references in YAML file (DB → TTL direction) def update_db_references(entity_type, missing_refs, output_file, include_potential = false) - # Try to get the original YAML file from the first entity - first_entity = missing_refs.first&.dig(:db_entity) - original_yaml_file = first_entity.send(:yaml_file) if first_entity.respond_to?( - :yaml_file, true - ) - - # If we can't get the path from the database object, use the output file path as a fallback - if original_yaml_file.nil? || !File.exist?(original_yaml_file) - puts "Warning: Could not determine original YAML file path. Using output file as template." - original_yaml_file = output_file - - # Create an empty template if output file doesn't exist - unless File.exist?(original_yaml_file) - FileUtils.mkdir_p(File.dirname(original_yaml_file)) - File.write(original_yaml_file, { entity_type => [] }.to_yaml) - end - end + original_yaml_file = resolve_yaml_file(output_file, entity_type) # Load the original YAML file yaml_content = File.read(original_yaml_file) @@ -146,7 +115,7 @@ def update_db_references(entity_type, missing_refs, output_file, # Check if it's an exact match or if we're including potential matches match_type = match_types[ttl_entity[:uri]] || "Exact match" # Default to exact match match_pair_key = "#{entity_id}:#{ttl_entity[:uri]}" - match_details = Unitsdb::Commands::CheckSi::SiMatcher.instance_variable_get(:@match_details)&.dig(match_pair_key) + match_details = Unitsdb::Commands::CheckSi::SiMatcher.match_details&.dig(match_pair_key) if match_details && %w[symbol_match partial_match].include?(match_details[:match_desc]) @@ -208,9 +177,20 @@ def update_db_references(entity_type, missing_refs, output_file, write_yaml_file(output_file, output_data) end + # Resolve which YAML file to read from. Caller supplies + # `output_file` as the canonical path; if it doesn't exist + # yet we seed it with an empty `{entity_type => []}` template. + def resolve_yaml_file(output_file, entity_type) + unless File.exist?(output_file) + FileUtils.mkdir_p(File.dirname(output_file)) + File.write(output_file, { entity_type => [] }.to_yaml) + end + output_file + end + # Helper to write YAML file + # Ensure the output directory exists def write_yaml_file(output_file, output_data) - # Ensure the output directory exists output_dir = File.dirname(output_file) FileUtils.mkdir_p(output_dir) diff --git a/lib/unitsdb/commands/entity_presenter.rb b/lib/unitsdb/commands/entity_presenter.rb new file mode 100644 index 0000000..5144527 --- /dev/null +++ b/lib/unitsdb/commands/entity_presenter.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Unitsdb + module Commands + # Present a single entity as text for the Get/Search CLI commands. + # Pulls every displayable field off the typed model — no + # `respond_to?` feature detection. New entity types extend + # `TYPE_NAME` and `extras`; nothing else changes. + class EntityPresenter + TYPE_NAME = { + Unitsdb::Unit => "Unit", + Unitsdb::Prefix => "Prefix", + Unitsdb::Quantity => "Quantity", + Unitsdb::Dimension => "Dimension", + Unitsdb::UnitSystem => "UnitSystem", + }.freeze + + def initialize(entity) + @entity = entity + end + + # Human-readable type label, e.g. "Unit", "Prefix". + def type_name + TYPE_NAME[@entity.class] || @entity.class.name.split("::").last + end + + # Best-effort display name: first localized name value → short → "N/A". + def display_name + name = @entity.names.first + return name.value.to_s if name&.value + return @entity.short.to_s if @entity.short + + "N/A" + end + + # Multi-line "details" output for `unitsdb get ID`. + def print_details + puts "Entity details:" + puts " - Type: #{type_name}" + puts " - Name: #{display_name}" + puts " - Description: #{@entity.short}" if show_short? + print_identifiers(header: " - Identifiers:", item_indent: " ") + print_extras + print_references + end + + # Single-block summary used by `unitsdb search` per result. + def print_summary + puts " - #{type_name}: #{display_name}" + print_identifiers(header: " IDs:", item_indent: " ") + puts " Description: #{@entity.short}" if show_short? + puts "" + end + + private + + def show_short? + @entity.short && @entity.short != display_name + end + + def print_identifiers(header:, item_indent:) + identifiers = @entity.identifiers + if identifiers.empty? + puts "#{header} None" + return + end + + puts header + identifiers.each do |id| + puts "#{item_indent}- #{id.id} (Type: #{id.type || 'N/A'})" + end + end + + def print_extras + case @entity + when Unitsdb::Unit + print_unit_symbols + end + end + + def print_unit_symbols + return unless @entity.symbols.any? + + puts " - Symbols:" + @entity.symbols.each { |s| puts " - #{s}" } + end + + def print_references + return unless @entity.references.any? + + puts " - References:" + @entity.references.each do |ref| + puts " - #{ref.type}: #{ref.uri}" + end + end + end + end +end diff --git a/lib/unitsdb/commands/get.rb b/lib/unitsdb/commands/get.rb index d9b573c..755da52 100644 --- a/lib/unitsdb/commands/get.rb +++ b/lib/unitsdb/commands/get.rb @@ -1,133 +1,36 @@ # frozen_string_literal: true -require "json" - module Unitsdb module Commands class Get < Base def get(id) - # Database path is guaranteed by Thor's global option id_type = @options[:id_type] format = @options[:format] || "text" - begin - database = load_database(@options[:database]) - - # Search by ID - entity = database.get_by_id(id: id, type: id_type) - - unless entity - puts "No entity found with ID: '#{id}'" - return - end + database = load_database(@options[:database]) + entity = database.get_by_id(id: id, type: id_type) - # Output based on format - if %w[json yaml].include?(format.downcase) - begin - puts entity.send("to_#{format.downcase}") - return - rescue NoMethodError - raise Unitsdb::Errors::InvalidFormatError, - "Unable to convert entity to #{format.upcase} format: output format not supported for this entity type" - end - end - - # Default text output - print_entity_details(entity) - rescue Unitsdb::Errors::DatabaseError => e - raise Unitsdb::Errors::DatabaseLoadError, - "Failed to load database: #{e.message}" - rescue StandardError => e - raise Unitsdb::Errors::CLIRuntimeError, "Search failed: #{e.message}" + if entity.nil? + puts "No entity found with ID: '#{id}'" + return end - end - - private - - def print_entity_details(entity) - # Determine entity type - entity_type = get_entity_type(entity) - - # Get name - name = get_entity_name(entity) - puts "Entity details:" - puts " - Type: #{entity_type}" - puts " - Name: #{name}" - - # Print description if available - puts " - Description: #{entity.short}" if entity.respond_to?(:short) && entity.short && entity.short != name - - # Print all identifiers - if entity.identifiers&.any? - puts " - Identifiers:" - entity.identifiers.each do |id| - puts " - #{id.id} (Type: #{id.type || 'N/A'})" - end + if %w[json yaml].include?(format.downcase) + print_serialized(entity, format.downcase) else - puts " - Identifiers: None" - end - - # Print additional properties based on entity type - case entity - when Unitsdb::Unit - puts " - Symbols:" if entity.respond_to?(:symbols) && entity.symbols&.any? - if entity.respond_to?(:symbols) && entity.symbols&.any? - entity.symbols.each do |s| - puts " - #{s}" - end - end - - puts " - Definition: #{entity.definition}" if entity.respond_to?(:definition) && entity.definition - - if entity.respond_to?(:dimensions) && entity.dimensions&.any? - puts " - Dimensions:" - entity.dimensions.each { |d| puts " - #{d}" } - end - when Unitsdb::Quantity - puts " - Dimensions: #{entity.dimension}" if entity.respond_to?(:dimension) && entity.dimension - when Unitsdb::Prefix - puts " - Value: #{entity.value}" if entity.respond_to?(:value) && entity.value - puts " - Symbol: #{entity.symbol}" if entity.respond_to?(:symbol) && entity.symbol - when Unitsdb::Dimension - # Any dimension-specific properties - when Unitsdb::UnitSystem - puts " - Organization: #{entity.organization}" if entity.respond_to?(:organization) && entity.organization - end - - # Print references if available - return unless entity.respond_to?(:references) && entity.references&.any? - - puts " - References:" - entity.references.each do |ref| - puts " - #{ref.type}: #{ref.uri}" + EntityPresenter.new(entity).print_details end + rescue Unitsdb::Errors::DatabaseError => e + raise Unitsdb::Errors::DatabaseLoadError, + "Failed to load database: #{e.message}" + rescue StandardError => e + raise Unitsdb::Errors::CLIRuntimeError, "Get failed: #{e.message}" end - def get_entity_type(entity) - case entity - when Unitsdb::Unit - "Unit" - when Unitsdb::Prefix - "Prefix" - when Unitsdb::Quantity - "Quantity" - when Unitsdb::Dimension - "Dimension" - when Unitsdb::UnitSystem - "UnitSystem" - else - "Unknown" - end - end - - def get_entity_name(entity) - # Using early returns is still preferable for simple conditions - return entity.names.first if entity.respond_to?(:names) && entity.names&.any? - return entity.name if entity.respond_to?(:name) && entity.name - return entity.short if entity.respond_to?(:short) && entity.short + private - "N/A" # Default if no name found + def print_serialized(entity, format) + puts entity.public_send("to_#{format}") end end end diff --git a/lib/unitsdb/commands/qudt.rb b/lib/unitsdb/commands/qudt.rb index 9bff137..e3cc305 100644 --- a/lib/unitsdb/commands/qudt.rb +++ b/lib/unitsdb/commands/qudt.rb @@ -12,11 +12,7 @@ module Qudt autoload :Updater, "unitsdb/commands/qudt/updater" end - class QudtCommand < Thor - # Inherit trace option from parent CLI - class_option :trace, type: :boolean, default: false, - desc: "Show full backtrace on error" - + class QudtCommand < Unitsdb::Commands::Thor desc "check", "Check QUDT references in UnitsDB" option :entity_type, type: :string, aliases: "-e", desc: "Entity type to check (units, quantities, dimensions, unit_systems). If not specified, all types are checked" @@ -48,35 +44,6 @@ def check def update run_command(Qudt::Update, options) end - - private - - def run_command(command_class, options) - command = command_class.new(options) - command.run - rescue Unitsdb::Errors::CLIRuntimeError => e - handle_cli_error(e) - rescue StandardError => e - handle_error(e) - end - - def handle_cli_error(error) - if options[:trace] - raise error - else - warn "Error: #{error.message}" - exit 1 - end - end - - def handle_error(error) - if options[:trace] - raise error - else - warn "Error: #{error.message}" - exit 1 - end - end end end end diff --git a/lib/unitsdb/commands/qudt/formatter.rb b/lib/unitsdb/commands/qudt/formatter.rb index d2dd3ca..66f20f0 100644 --- a/lib/unitsdb/commands/qudt/formatter.rb +++ b/lib/unitsdb/commands/qudt/formatter.rb @@ -90,17 +90,15 @@ def format_qudt_entity_details(entity) details = "DIMENSION VECTOR: #{entity.label || 'No label'}" details += "\n URI: #{entity.uri}" details += "\n Description: #{entity.description}" if entity.description - if entity.respond_to?(:dimension_exponent_for_length) - exponents = [] - exponents << "L:#{entity.dimension_exponent_for_length}" if entity.dimension_exponent_for_length != 0 - exponents << "M:#{entity.dimension_exponent_for_mass}" if entity.dimension_exponent_for_mass != 0 - exponents << "T:#{entity.dimension_exponent_for_time}" if entity.dimension_exponent_for_time != 0 - exponents << "I:#{entity.dimension_exponent_for_electric_current}" if entity.dimension_exponent_for_electric_current != 0 - exponents << "Θ:#{entity.dimension_exponent_for_thermodynamic_temperature}" if entity.dimension_exponent_for_thermodynamic_temperature != 0 - exponents << "N:#{entity.dimension_exponent_for_amount_of_substance}" if entity.dimension_exponent_for_amount_of_substance != 0 - exponents << "J:#{entity.dimension_exponent_for_luminous_intensity}" if entity.dimension_exponent_for_luminous_intensity != 0 - details += "\n Exponents: #{exponents.join(', ')}" unless exponents.empty? - end + exponents = [] + exponents << "L:#{entity.dimension_exponent_for_length}" if entity.dimension_exponent_for_length != 0 + exponents << "M:#{entity.dimension_exponent_for_mass}" if entity.dimension_exponent_for_mass != 0 + exponents << "T:#{entity.dimension_exponent_for_time}" if entity.dimension_exponent_for_time != 0 + exponents << "I:#{entity.dimension_exponent_for_electric_current}" if entity.dimension_exponent_for_electric_current != 0 + exponents << "Θ:#{entity.dimension_exponent_for_thermodynamic_temperature}" if entity.dimension_exponent_for_thermodynamic_temperature != 0 + exponents << "N:#{entity.dimension_exponent_for_amount_of_substance}" if entity.dimension_exponent_for_amount_of_substance != 0 + exponents << "J:#{entity.dimension_exponent_for_luminous_intensity}" if entity.dimension_exponent_for_luminous_intensity != 0 + details += "\n Exponents: #{exponents.join(', ')}" unless exponents.empty? details when Unitsdb::QudtSystemOfUnits details = "SYSTEM OF UNITS: #{entity.label || 'No label'}" @@ -157,26 +155,17 @@ def display_db_results(entity_type, matches, missing_refs, unmatched_db) # Helper to get db entity id def get_db_entity_id(entity) - if entity.respond_to?(:identifiers) && entity.identifiers && !entity.identifiers.empty? - entity.identifiers.first.id - elsif entity.respond_to?(:id) - entity.id - else - "unknown-id" - end + return entity.identifiers.first.id unless entity.identifiers.empty? + + entity.short || "unknown-id" end # Helper to get db entity name def get_db_entity_name(entity) - if entity.respond_to?(:names) && entity.names && !entity.names.empty? - entity.names.first.value - elsif entity.respond_to?(:short) && entity.short - entity.short - elsif entity.respond_to?(:name) - entity.name - else - "unknown-name" - end + return entity.names.first.value unless entity.names.empty? + return entity.short if entity.short + + "unknown-name" end # Helper to get qudt entity name diff --git a/lib/unitsdb/commands/qudt/matcher.rb b/lib/unitsdb/commands/qudt/matcher.rb index 18753d5..8e64246 100644 --- a/lib/unitsdb/commands/qudt/matcher.rb +++ b/lib/unitsdb/commands/qudt/matcher.rb @@ -70,14 +70,14 @@ def match_db_to_qudt(entity_type, qudt_entities, db_entities) # Check if a UnitsDB entity already has a QUDT reference def has_qudt_reference?(entity) - return false unless entity.respond_to?(:references) && entity.references + return false unless entity.references entity.references.any? { |ref| ref.authority == "qudt" } end # Find the referenced QUDT entity based on the reference URI def find_referenced_qudt_entity(db_entity, qudt_entities) - return nil unless db_entity.respond_to?(:references) && db_entity.references + return nil unless db_entity.references qudt_ref = db_entity.references.find { |ref| ref.authority == "qudt" } return nil unless qudt_ref @@ -87,8 +87,8 @@ def find_referenced_qudt_entity(db_entity, qudt_entities) end # Get the ID of a UnitsDB entity - def get_entity_id(entity) - entity.respond_to?(:id) ? entity.id : nil + def get_entity_id(_entity) + nil end # Find a matching UnitsDB entity for a QUDT entity @@ -520,7 +520,7 @@ def match_unit_system_db_to_qudt(db_system, qudt_systems) # Check if QUDT dimension vector matches UnitsDB dimension def dimensions_match?(qudt_dimension, db_dimension) - return false unless qudt_dimension.respond_to?(:dimension_exponent_for_length) + return false unless qudt_dimension.is_a?(Unitsdb::QudtDimensionVector) # Map QUDT dimension exponents to UnitsDB dimension structure qudt_exponents = { @@ -552,14 +552,14 @@ def dimensions_match?(qudt_dimension, db_dimension) qudt_exponents == db_exponents end - # Get dimension power from UnitsDB dimension entity + # Get dimension power from UnitsDB dimension entity. + # Dimension declares all seven dimension-type attrs as + # DimensionDetails; DimensionDetails always has `power`. def get_dimension_power(db_dimension, dimension_type) - return 0 unless db_dimension.respond_to?(dimension_type) + property = db_dimension.public_send(dimension_type) + return 0 unless property - dimension_property = db_dimension.send(dimension_type) - return 0 unless dimension_property.respond_to?(:power) - - dimension_property.power || 0 + property.power || 0 end # Normalize names by removing common variations and punctuation @@ -585,7 +585,7 @@ def find_unit_by_si_reference(si_uri, db_units) # Look for a UnitsDB unit that has an SI reference with this identifier db_units.find do |db_unit| - next unless db_unit.respond_to?(:references) && db_unit.references + next unless db_unit.references db_unit.references.any? do |ref| ref.authority == "si" && ( @@ -603,7 +603,7 @@ def match_prefix_qudt_to_db(qudt_prefix, db_prefixes) # PRIORITY 1: Try UCUM code match first (most reliable for prefixes) if qudt_prefix.ucum_code ucum_match = db_prefixes.find do |db_prefix| - db_prefix.respond_to?(:references) && db_prefix.references&.any? do |ref| + (db_prefix.references || [])&.any? do |ref| ref.authority == "ucum" && ref.uri&.include?(qudt_prefix.ucum_code) end end @@ -644,17 +644,7 @@ def match_prefix_qudt_to_db(qudt_prefix, db_prefixes) end # PRIORITY 4: Try multiplier match (for prefixes with same scale factor) - if qudt_prefix.prefix_multiplier - multiplier_match = db_prefixes.find do |db_prefix| - db_prefix.respond_to?(:factor) && - (db_prefix.factor - qudt_prefix.prefix_multiplier).abs < 1e-10 - end - - if multiplier_match - result[:match] = multiplier_match - return result - end - end + # Skipped: Unitsdb::Prefix has no `factor` attribute in 2.0. # PRIORITY 5: Try normalized name matching if qudt_prefix.label @@ -720,7 +710,7 @@ def match_prefix_db_to_qudt(db_prefix, qudt_prefixes) end # PRIORITY 4: Try multiplier match (for prefixes with same scale factor) - if db_prefix.respond_to?(:factor) && db_prefix.factor + if false multiplier_match = qudt_prefixes.find do |qudt_prefix| qudt_prefix.prefix_multiplier && (qudt_prefix.prefix_multiplier - db_prefix.factor).abs < 1e-10 @@ -734,10 +724,10 @@ def match_prefix_db_to_qudt(db_prefix, qudt_prefixes) # Check if an entity has been manually verified (has a special flag) def manually_verified?(entity) - return false unless entity.respond_to?(:references) && entity.references + return false unless entity.references - entity.references.any? do |ref| - ref.authority == "qudt" && ref.respond_to?(:verified) && ref.verified + entity.references.any? do |_ref| + false end end end diff --git a/lib/unitsdb/commands/qudt/updater.rb b/lib/unitsdb/commands/qudt/updater.rb index adb4ef9..a2053e0 100644 --- a/lib/unitsdb/commands/qudt/updater.rb +++ b/lib/unitsdb/commands/qudt/updater.rb @@ -168,20 +168,17 @@ def get_original_yaml_file(_db_entities, output_file) # Get entity ID (either from identifiers array or directly) def get_entity_id(entity) - if entity.respond_to?(:identifiers) && entity.identifiers && !entity.identifiers.empty? - entity.identifiers.first.id - elsif entity.respond_to?(:id) - entity.id - end + return entity.identifiers.first.id unless entity.identifiers.empty? + + entity.short end # Check if an entity has been manually verified (has a special flag) - def manually_verified?(entity) - return false unless entity.respond_to?(:references) && entity.references - - entity.references.any? do |ref| - ref.authority == QUDT_AUTHORITY && ref.respond_to?(:verified) && ref.verified - end + # Unitsdb::ExternalReference carries no `verified` flag in the + # 2.0 schema, so nothing is ever manually verified. Kept as a + # stub for callers; returns false unconditionally. + def manually_verified?(_entity) + false end end end diff --git a/lib/unitsdb/commands/search.rb b/lib/unitsdb/commands/search.rb index 799cc1c..93c59d9 100644 --- a/lib/unitsdb/commands/search.rb +++ b/lib/unitsdb/commands/search.rb @@ -1,219 +1,64 @@ # frozen_string_literal: true -require "json" - module Unitsdb module Commands class Search < Base def run(query) - # Database path is guaranteed by Thor's global option - type = @options[:type] id = @options[:id] id_type = @options[:id_type] format = @options[:format] || "text" - begin - database = load_database(@options[:database]) - - # Search by ID (early return) - if id - entity = database.get_by_id(id: id, type: id_type) - - unless entity - puts "No entity found with ID: '#{id}'" - return - end - - # Use the same output logic as the Get command - if %w[json yaml].include?(format.downcase) - begin - puts entity.send("to_#{format.downcase}") - return - rescue NoMethodError - raise Unitsdb::Errors::InvalidFormatError, - "Unable to convert entity to #{format.upcase} format: output format not supported for this entity type" - end - end - - print_entity_details(entity) - return - end - - # Regular text search - results = database.search(text: query, type: type) - - # Early return for empty results - if results.empty? - puts "No results found for '#{query}'" - return - end - - # Format-specific output - if %w[json yaml].include?(format.downcase) - temp_db = create_temporary_database(results) - puts temp_db.send("to_#{format.downcase}") - return - end + database = load_database(@options[:database]) - # Default text output - puts "Found #{results.size} result(s) for '#{query}':" - results.each do |entity| - print_entity_with_ids(entity) - end - rescue Unitsdb::Errors::DatabaseError => e - raise Unitsdb::Errors::DatabaseLoadError, - "Failed to load database: #{e.message}" - rescue StandardError => e - raise Unitsdb::Errors::CLIRuntimeError, "Search failed: #{e.message}" + if id + lookup_by_id(database, id, id_type, format) + return end - end - - private - - def print_entity_with_ids(entity) - # Determine entity type - entity_type = get_entity_type(entity) - - # Get name - name = get_entity_name(entity) - # Get all identifiers - identifiers = entity.identifiers || [] - - # Print entity information - puts " - #{entity_type}: #{name}" - - # Print each identifier on its own line for better readability - if identifiers.empty? - puts " ID: None" - else - puts " IDs:" - identifiers.each do |id| - puts " - #{id.id} (Type: #{id.type || 'N/A'})" - end + results = database.search(text: query, type: type) + if results.empty? + puts "No results found for '#{query}'" + return end - # If entity has a short description, print it - puts " Description: #{entity.short}" if entity.respond_to?(:short) && entity.short && entity.short != name - - # Add a blank line for readability - puts "" + print_results(results, query, format) + rescue Unitsdb::Errors::DatabaseError => e + raise Unitsdb::Errors::DatabaseLoadError, + "Failed to load database: #{e.message}" + rescue StandardError => e + raise Unitsdb::Errors::CLIRuntimeError, "Search failed: #{e.message}" end - def print_entity_details(entity) - # Determine entity type - entity_type = get_entity_type(entity) - - # Get name - name = get_entity_name(entity) - - puts "Entity details:" - puts " - Type: #{entity_type}" - puts " - Name: #{name}" - - # Print description if available - puts " - Description: #{entity.short}" if entity.respond_to?(:short) && entity.short && entity.short != name - - # Print all identifiers - if entity.identifiers&.any? - puts " - Identifiers:" - entity.identifiers.each do |id| - puts " - #{id.id} (Type: #{id.type || 'N/A'})" - end - else - puts " - Identifiers: None" - end - - # Print additional properties based on entity type - case entity - when Unitsdb::Unit - puts " - Symbols:" if entity.respond_to?(:symbols) && entity.symbols&.any? - if entity.respond_to?(:symbols) && entity.symbols&.any? - entity.symbols.each do |s| - puts " - #{s}" - end - end - - puts " - Definition: #{entity.definition}" if entity.respond_to?(:definition) && entity.definition - - if entity.respond_to?(:dimensions) && entity.dimensions&.any? - puts " - Dimensions:" - entity.dimensions.each { |d| puts " - #{d}" } - end - when Unitsdb::Quantity - puts " - Dimensions: #{entity.dimension}" if entity.respond_to?(:dimension) && entity.dimension - when Unitsdb::Prefix - puts " - Value: #{entity.value}" if entity.respond_to?(:value) && entity.value - puts " - Symbol: #{entity.symbol}" if entity.respond_to?(:symbol) && entity.symbol - when Unitsdb::Dimension - # Any dimension-specific properties - when Unitsdb::UnitSystem - puts " - Organization: #{entity.organization}" if entity.respond_to?(:organization) && entity.organization - end - - # Print references if available - return unless entity.respond_to?(:references) && entity.references&.any? + private - puts " - References:" - entity.references.each do |ref| - puts " - #{ref.type}: #{ref.id}" + def lookup_by_id(database, id, id_type, format) + entity = database.get_by_id(id: id, type: id_type) + if entity.nil? + puts "No entity found with ID: '#{id}'" + return end - end - def get_entity_type(entity) - case entity - when Unitsdb::Unit - "Unit" - when Unitsdb::Prefix - "Prefix" - when Unitsdb::Quantity - "Quantity" - when Unitsdb::Dimension - "Dimension" - when Unitsdb::UnitSystem - "UnitSystem" + if %w[json yaml].include?(format.downcase) + print_serialized(entity, format.downcase) else - "Unknown" + EntityPresenter.new(entity).print_details end end - def get_entity_name(entity) - # Using early returns is still preferable for simple conditions - return entity.names.first if entity.respond_to?(:names) && entity.names&.any? - return entity.name if entity.respond_to?(:name) && entity.name - return entity.short if entity.respond_to?(:short) && entity.short + def print_results(results, query, format) + if %w[json yaml].include?(format.downcase) + temp_db = Database.empty_for_results(results) + print_serialized(temp_db, format.downcase) + return + end - "N/A" # Default if no name found + puts "Found #{results.size} result(s) for '#{query}':" + results.each { |entity| EntityPresenter.new(entity).print_summary } end - def create_temporary_database(results) - temp_db = Unitsdb::Database.new - - # Initialize collections - temp_db.units = [] - temp_db.prefixes = [] - temp_db.quantities = [] - temp_db.dimensions = [] - temp_db.unit_systems = [] - - # Add results to appropriate collection based on type using case statement - results.each do |entity| - case entity - when Unitsdb::Unit - temp_db.units << entity - when Unitsdb::Prefix - temp_db.prefixes << entity - when Unitsdb::Quantity - temp_db.quantities << entity - when Unitsdb::Dimension - temp_db.dimensions << entity - when Unitsdb::UnitSystem - temp_db.unit_systems << entity - end - end - - temp_db + def print_serialized(entity, format) + puts entity.public_send("to_#{format}") end end end diff --git a/lib/unitsdb/commands/thor.rb b/lib/unitsdb/commands/thor.rb new file mode 100644 index 0000000..2c4d81d --- /dev/null +++ b/lib/unitsdb/commands/thor.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "thor" + +module Unitsdb + module Commands + # Base class for every Thor-based CLI surface in the gem. Owns the + # shared `--trace` option, the `exit_on_failure?` policy, the + # `run_command` dispatch helper, and the single error handler + # (`handle_error`). Subclasses inherit these and provide only their + # own `desc`/`option`/subcommand declarations. + class Thor < ::Thor + class_option :trace, type: :boolean, default: false, + desc: "Show full backtrace on error" + + def self.exit_on_failure? + true + end + + private + + # Instantiate `command_class` with `options` plus any extra + # positional args, then call its `run` (or other public method). + # All exceptions route through `handle_error`. + def run_command(command_class, options, *args, method: :run) + command_class.new(options).public_send(method, *args) + rescue StandardError => e + handle_error(e) + end + + # Re-raise when `--trace` is set so the user sees the full + # backtrace. Otherwise warn-and-exit-1 for a clean CLI UX. + def handle_error(error) + raise error if options[:trace] + + warn "Error: #{error.message}" + exit 1 + end + end + end +end diff --git a/lib/unitsdb/commands/ucum.rb b/lib/unitsdb/commands/ucum.rb index 6cf6963..26f68f9 100644 --- a/lib/unitsdb/commands/ucum.rb +++ b/lib/unitsdb/commands/ucum.rb @@ -13,11 +13,7 @@ module Ucum autoload :XmlParser, "unitsdb/commands/ucum/xml_parser" end - class UcumCommand < Thor - # Inherit trace option from parent CLI - class_option :trace, type: :boolean, default: false, - desc: "Show full backtrace on error" - + class UcumCommand < Unitsdb::Commands::Thor desc "check", "Check UCUM references in UnitsDB" option :entity_type, type: :string, aliases: "-e", desc: "Entity type to check (units, prefixes). If not specified, all types are checked" @@ -49,35 +45,6 @@ def check def update run_command(Ucum::Update, options) end - - private - - def run_command(command_class, options) - command = command_class.new(options) - command.run - rescue Unitsdb::Errors::CLIRuntimeError => e - handle_cli_error(e) - rescue StandardError => e - handle_error(e) - end - - def handle_cli_error(error) - if options[:trace] - raise error - else - warn "Error: #{error.message}" - exit 1 - end - end - - def handle_error(error) - if options[:trace] - raise error - else - warn "Error: #{error.message}" - exit 1 - end - end end end end diff --git a/lib/unitsdb/commands/ucum/formatter.rb b/lib/unitsdb/commands/ucum/formatter.rb index 173f0cf..c3ed9e5 100644 --- a/lib/unitsdb/commands/ucum/formatter.rb +++ b/lib/unitsdb/commands/ucum/formatter.rb @@ -48,7 +48,7 @@ def display_ucum_results(entity_type, matches, missing_matches, db_id = get_db_entity_id(db_entity) db_name = get_db_entity_name(db_entity) ucum_name = get_ucum_entity_name(ucum_entity) - ucum_code = ucum_entity.respond_to?(:code_sensitive) ? ucum_entity.code_sensitive : "unknown" + ucum_code = ucum_entity.code_sensitive || "unknown" case ucum_entity when Unitsdb::UcumPrefix @@ -88,7 +88,7 @@ def display_db_results(entity_type, matches, missing_refs, unmatched_db) db_id = get_db_entity_id(db_entity) db_name = get_db_entity_name(db_entity) ucum_name = get_ucum_entity_name(ucum_entity) - ucum_code = ucum_entity.respond_to?(:code_sensitive) ? ucum_entity.code_sensitive : "unknown" + ucum_code = ucum_entity.code_sensitive || "unknown" case ucum_entity when Unitsdb::UcumPrefix @@ -103,26 +103,17 @@ def display_db_results(entity_type, matches, missing_refs, unmatched_db) # Helper to get db entity id def get_db_entity_id(entity) - if entity.respond_to?(:identifiers) && entity.identifiers && !entity.identifiers.empty? - entity.identifiers.first.id - elsif entity.respond_to?(:id) - entity.id - else - "unknown-id" - end + return entity.identifiers.first.id unless entity.identifiers.empty? + + entity.short || "unknown-id" end # Helper to get db entity name def get_db_entity_name(entity) - if entity.respond_to?(:names) && entity.names && !entity.names.empty? - entity.names.first.value - elsif entity.respond_to?(:short) && entity.short - entity.short - elsif entity.respond_to?(:name) - entity.name - else - "unknown-name" - end + return entity.names.first.value unless entity.names.empty? + return entity.short if entity.short + + "unknown-name" end # Helper to get ucum entity name diff --git a/lib/unitsdb/commands/ucum/matcher.rb b/lib/unitsdb/commands/ucum/matcher.rb index 4b904b5..6b3322d 100644 --- a/lib/unitsdb/commands/ucum/matcher.rb +++ b/lib/unitsdb/commands/ucum/matcher.rb @@ -70,14 +70,14 @@ def match_db_to_ucum(entity_type, ucum_entities, db_entities) # Check if a UnitsDB entity already has a UCUM reference def has_ucum_reference?(entity) - return false unless entity.respond_to?(:references) && entity.references + return false unless entity.references entity.references.any? { |ref| ref.authority == "ucum" } end # Find the referenced UCUM entity based on the reference URI def find_referenced_ucum_entity(db_entity, ucum_entities) - return nil unless db_entity.respond_to?(:references) && db_entity.references + return nil unless db_entity.references ucum_ref = db_entity.references.find { |ref| ref.authority == "ucum" } return nil unless ucum_ref @@ -87,8 +87,8 @@ def find_referenced_ucum_entity(db_entity, ucum_entities) end # Get the ID of a UnitsDB entity - def get_entity_id(entity) - entity.respond_to?(:id) ? entity.id : nil + def get_entity_id(_entity) + nil end # Find a matching UnitsDB entity for a UCUM entity diff --git a/lib/unitsdb/commands/ucum/updater.rb b/lib/unitsdb/commands/ucum/updater.rb index ede3a83..3cff408 100644 --- a/lib/unitsdb/commands/ucum/updater.rb +++ b/lib/unitsdb/commands/ucum/updater.rb @@ -120,11 +120,9 @@ def preserve_schema_header(original_file, yaml_content) # Get entity ID (either from identifiers array or directly) def get_entity_id(entity) - if entity.respond_to?(:identifiers) && entity.identifiers && !entity.identifiers.empty? - entity.identifiers.first.id - elsif entity.respond_to?(:id) - entity.id - end + return entity.identifiers.first.id unless entity.identifiers.empty? + + entity.short end end end diff --git a/lib/unitsdb/commands/validate.rb b/lib/unitsdb/commands/validate.rb index fb40c2f..6313a8e 100644 --- a/lib/unitsdb/commands/validate.rb +++ b/lib/unitsdb/commands/validate.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "thor" - module Unitsdb module Commands module Validate @@ -12,11 +10,7 @@ module Validate autoload :UcumReferences, "unitsdb/commands/validate/ucum_references" end - class ValidateCommand < Thor - # Inherit trace option from parent CLI - class_option :trace, type: :boolean, default: false, - desc: "Show full backtrace on error" - + class ValidateCommand < Unitsdb::Commands::Thor desc "references", "Validate that all references exist" option :debug_registry, type: :boolean, desc: "Show registry contents for debugging" @@ -62,35 +56,6 @@ def qudt_references def ucum_references run_command(Commands::Validate::UcumReferences, options) end - - private - - def run_command(command_class, options) - command = command_class.new(options) - command.run - rescue Unitsdb::Errors::CLIRuntimeError => e - handle_cli_error(e) - rescue StandardError => e - handle_error(e) - end - - def handle_cli_error(error) - if options[:trace] - raise error - else - warn "Error: #{error.message}" - exit 1 - end - end - - def handle_error(error) - if options[:trace] - raise error - else - warn "Error: #{error.message}" - exit 1 - end - end end end end diff --git a/lib/unitsdb/commands/validate/qudt_references.rb b/lib/unitsdb/commands/validate/qudt_references.rb index c3ef985..737a864 100644 --- a/lib/unitsdb/commands/validate/qudt_references.rb +++ b/lib/unitsdb/commands/validate/qudt_references.rb @@ -3,16 +3,15 @@ module Unitsdb module Commands module Validate + # `unitsdb validate qudt_references`. Checks that no QUDT + # reference URI is used by more than one entity of a given type. class QudtReferences < Unitsdb::Commands::Base + AUTHORITY = "qudt" + def run - # Load the database db = load_database(@options[:database]) - - # Check for duplicate QUDT references - duplicates = check_qudt_references(db) - - # Display results - display_duplicate_results(duplicates) + duplicates = scan(db) + display(duplicates) rescue Unitsdb::Errors::DatabaseError => e raise Unitsdb::Errors::ValidationError, "Failed to validate QUDT references: #{e.message}" @@ -20,90 +19,50 @@ def run private - def check_qudt_references(db) - duplicates = {} - - # Check units - check_entity_qudt_references(db.units, "units", duplicates) - - # Check quantities - check_entity_qudt_references(db.quantities, "quantities", duplicates) - - # Check dimensions - check_entity_qudt_references(db.dimensions, "dimensions", duplicates) - - # Check unit_systems - check_entity_qudt_references(db.unit_systems, "unit_systems", - duplicates) - - duplicates + def scan(db) + {}.tap do |duplicates| + Unitsdb::Database::COLLECTIONS.each do |name| + collection = db.collection(name) || [] + dup = scan_collection(collection) + duplicates[name.to_s] = dup unless dup.empty? + end + end end - def check_entity_qudt_references(entities, entity_type, duplicates) - # Track QUDT references by URI - qudt_refs = {} - + def scan_collection(entities) + by_uri = {} entities.each_with_index do |entity, index| - # Skip if no references - next unless entity.respond_to?(:references) && entity.references - - # Check each reference - entity.references.each do |ref| - # Only interested in qudt references - next unless ref.authority == "qudt" - - # Get entity info for display - entity_id = if entity.respond_to?(:identifiers) && entity.identifiers&.first.respond_to?(:id) - entity.identifiers.first.id - else - entity.short - end + refs = entity.references || [] + refs.each do |ref| + next unless ref.authority == AUTHORITY - # Track this reference - qudt_refs[ref.uri] ||= [] - qudt_refs[ref.uri] << { - entity_id: entity_id, - entity_name: entity.respond_to?(:names) ? entity.names.first : entity.short, + (by_uri[ref.uri] ||= []) << { + entity_id: entity.identifiers.first&.id || entity.short, + entity_name: entity.names.first&.value || entity.short, index: index, } end end - - # Find duplicates (URIs with more than one entity) - qudt_refs.each do |uri, entities| - next unless entities.size > 1 - - # Record this duplicate - duplicates[entity_type] ||= {} - duplicates[entity_type][uri] = entities - end + by_uri.reject { |_, refs| refs.size == 1 } end - def display_duplicate_results(duplicates) + def display(duplicates) if duplicates.empty? - puts "No duplicate QUDT references found! Each QUDT reference URI is used by at most one entity of each type." + puts "No duplicate QUDT references found! " \ + "Each QUDT reference URI is used by at most one entity of each type." return end puts "Found duplicate QUDT references:" - duplicates.each do |entity_type, uri_duplicates| puts "\n #{entity_type.capitalize}:" - - uri_duplicates.each do |uri, entities| + uri_duplicates.each do |uri, refs| puts " QUDT URI: #{uri}" - puts " Used by #{entities.size} entities:" - - entities.each do |entity| - puts " - #{entity[:entity_id]} (#{entity[:entity_name]}) at index #{entity[:index]}" - end + puts " Used by #{refs.size} entities:" + refs.each { |r| puts " - #{r[:entity_id]} (#{r[:entity_name]}) at index #{r[:index]}" } puts "" end end - - puts "\nEach QUDT reference should be used by at most one entity of each type." - puts "Please fix the duplicates by either removing the reference from all but one entity," - puts "or by updating the references to use different URIs appropriate for each entity." end end end diff --git a/lib/unitsdb/commands/validate/references.rb b/lib/unitsdb/commands/validate/references.rb index 0e31280..0d23ff0 100644 --- a/lib/unitsdb/commands/validate/references.rb +++ b/lib/unitsdb/commands/validate/references.rb @@ -3,19 +3,13 @@ module Unitsdb module Commands module Validate + # `unitsdb validate references`. Thin presenter around + # Database::ReferenceValidator — owns output formatting only. class References < Unitsdb::Commands::Base def run - # Load the database db = load_database(@options[:database]) - - # Build registry of all valid IDs - registry = build_id_registry(db) - - # Check all references - invalid_refs = check_references(db, registry) - - # Display results - display_reference_results(invalid_refs, registry) + result = Unitsdb::Database::ReferenceValidator.new(db).validate + display_result(result.invalid) rescue Unitsdb::Errors::DatabaseError => e raise Unitsdb::Errors::ValidationError, "Failed to validate references: #{e.message}" @@ -23,309 +17,17 @@ def run private - def build_id_registry(db) - registry = {} - - # Add all unit identifiers to the registry - registry["units"] = {} - db.units.each_with_index do |unit, index| - unit.identifiers.each do |identifier| - next unless identifier.id && identifier.type - - # Add the composite key (type:id) - composite_key = "#{identifier.type}:#{identifier.id}" - registry["units"][composite_key] = "index:#{index}" - - # Also add just the ID for backward compatibility - registry["units"][identifier.id] = "index:#{index}" - end - end - - # Add dimension identifiers - registry["dimensions"] = {} - db.dimensions.each_with_index do |dimension, index| - dimension.identifiers.each do |identifier| - next unless identifier.id && identifier.type - - composite_key = "#{identifier.type}:#{identifier.id}" - registry["dimensions"][composite_key] = "index:#{index}" - registry["dimensions"][identifier.id] = "index:#{index}" - end - - # Also track dimensions by short name - if dimension.respond_to?(:short) && dimension.short - registry["dimensions_short"] ||= {} - registry["dimensions_short"][dimension.short] = "index:#{index}" - end - end - - # Add quantity identifiers - registry["quantities"] = {} - db.quantities.each_with_index do |quantity, index| - quantity.identifiers.each do |identifier| - next unless identifier.id && identifier.type - - composite_key = "#{identifier.type}:#{identifier.id}" - registry["quantities"][composite_key] = "index:#{index}" - registry["quantities"][identifier.id] = "index:#{index}" - end - end - - # Add prefix identifiers - registry["prefixes"] = {} - db.prefixes.each_with_index do |prefix, index| - prefix.identifiers.each do |identifier| - next unless identifier.id && identifier.type - - composite_key = "#{identifier.type}:#{identifier.id}" - registry["prefixes"][composite_key] = "index:#{index}" - registry["prefixes"][identifier.id] = "index:#{index}" - end - end - - # Add unit system identifiers - registry["unit_systems"] = {} - db.unit_systems.each_with_index do |unit_system, index| - unit_system.identifiers.each do |identifier| - next unless identifier.id && identifier.type - - composite_key = "#{identifier.type}:#{identifier.id}" - registry["unit_systems"][composite_key] = "index:#{index}" - registry["unit_systems"][identifier.id] = "index:#{index}" - end - - # Also track unit systems by short name - if unit_system.respond_to?(:short) && unit_system.short - registry["unit_systems_short"] ||= {} - registry["unit_systems_short"][unit_system.short] = - "index:#{index}" - end - end - - # Debug registry if requested - if @options[:debug_registry] - puts "Registry contents:" - registry.each do |type, ids| - puts " #{type}:" - ids.each do |id, location| - puts " #{id} => #{location}" - end - end - end - - registry - end - - def check_references(db, registry) - invalid_refs = {} - - # Check unit references in dimensions - check_dimension_references(db, registry, invalid_refs) - - # Check unit_system references - check_unit_system_references(db, registry, invalid_refs) - - # Check quantity references - check_quantity_references(db, registry, invalid_refs) - - # Check root unit references in units - check_root_unit_references(db, registry, invalid_refs) - - invalid_refs - end - - def check_dimension_references(db, registry, invalid_refs) - db.dimensions.each_with_index do |dimension, index| - next unless dimension.respond_to?(:dimension_reference) && dimension.dimension_reference - - ref_id = dimension.dimension_reference - ref_type = "dimensions" - ref_path = "dimensions:index:#{index}:dimension_reference" - - validate_reference(ref_id, ref_type, ref_path, registry, - invalid_refs, "dimensions") - end - end - - def check_unit_system_references(db, registry, invalid_refs) - db.units.each_with_index do |unit, index| - next unless unit.respond_to?(:unit_system_reference) && unit.unit_system_reference - - unit.unit_system_reference.each_with_index do |ref_id, idx| - ref_type = "unit_systems" - ref_path = "units:index:#{index}:unit_system_reference[#{idx}]" - - validate_reference(ref_id, ref_type, ref_path, registry, - invalid_refs, "units") - end - end - end - - def check_quantity_references(db, registry, invalid_refs) - db.units.each_with_index do |unit, index| - next unless unit.respond_to?(:quantity_references) && unit.quantity_references - - unit.quantity_references.each_with_index do |ref_id, idx| - ref_type = "quantities" - ref_path = "units:index:#{index}:quantity_references[#{idx}]" - - validate_reference(ref_id, ref_type, ref_path, registry, - invalid_refs, "units") - end - end - end - - def check_root_unit_references(db, registry, invalid_refs) - db.units.each_with_index do |unit, index| - next unless unit.respond_to?(:root_units) && unit.root_units - - unit.root_units.each_with_index do |root_unit, idx| - next unless root_unit.respond_to?(:unit_reference) && root_unit.unit_reference - - # Check unit reference - ref_id = root_unit.unit_reference - ref_type = "units" - ref_path = "units:index:#{index}:root_units.#{idx}.unit_reference" - - validate_reference(ref_id, ref_type, ref_path, registry, - invalid_refs, "units") - - # Check prefix reference if present - next unless root_unit.respond_to?(:prefix_reference) && root_unit.prefix_reference - - ref_id = root_unit.prefix_reference - ref_type = "prefixes" - ref_path = "units:index:#{index}:root_units.#{idx}.prefix_reference" - - validate_reference(ref_id, ref_type, ref_path, registry, - invalid_refs, "units") - end - end - end - - 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 - type = ref_id.type - composite_key = "#{type}:#{id}" - - # Try multiple lookup strategies - valid = false - - # 1. Try exact composite key match - valid = true if registry.key?(ref_type) && registry[ref_type].key?(composite_key) - - # 2. Try just ID match if composite didn't work - valid = true if !valid && registry.key?(ref_type) && registry[ref_type].key?(id) - - # 3. Try alternate ID formats for unit systems (e.g., SI_base vs si-base) - if !valid && type == "unitsml" && ref_type == "unit_systems" && registry.key?(ref_type) && ( - registry[ref_type].keys.any? { |k| k.end_with?(":#{id}") } || - registry[ref_type].keys.any? do |k| - k.end_with?(":SI_#{id.sub('si-', '')}") - end || - registry[ref_type].keys.any? do |k| - k.end_with?(":non-SI_#{id.sub('nonsi-', '')}") - end - ) - # Special handling for unit_systems between unitsml and nist types - valid = true - end - - if valid - puts "Valid reference: #{id} (#{type}) at #{file_type}:#{ref_path}" if @options[:print_valid] - else - invalid_refs[file_type] ||= {} - invalid_refs[file_type][ref_path] = - { id: id, type: type, ref_type: ref_type } - end - # Handle references that are objects with id and type in a hash - elsif ref_id.is_a?(Hash) && ref_id.key?("id") && ref_id.key?("type") - id = ref_id["id"] - type = ref_id["type"] - composite_key = "#{type}:#{id}" - - # Try multiple lookup strategies - valid = false - - # 1. Try exact composite key match - valid = true if registry.key?(ref_type) && registry[ref_type].key?(composite_key) - - # 2. Try just ID match if composite didn't work - valid = true if !valid && registry.key?(ref_type) && registry[ref_type].key?(id) - - # 3. Try alternate ID formats for unit systems (e.g., SI_base vs si-base) - if !valid && type == "unitsml" && ref_type == "unit_systems" && registry.key?(ref_type) && ( - registry[ref_type].keys.any? { |k| k.end_with?(":#{id}") } || - registry[ref_type].keys.any? do |k| - k.end_with?(":SI_#{id.sub('si-', '')}") - end || - registry[ref_type].keys.any? do |k| - k.end_with?(":non-SI_#{id.sub('nonsi-', '')}") - end - ) - # Special handling for unit_systems between unitsml and nist types - valid = true - end - - if valid - puts "Valid reference: #{id} (#{type}) at #{file_type}:#{ref_path}" if @options[:print_valid] - else - invalid_refs[file_type] ||= {} - invalid_refs[file_type][ref_path] = - { id: id, type: type, ref_type: ref_type } - end - else - # Handle plain string references (legacy format) - valid = registry.key?(ref_type) && registry[ref_type].key?(ref_id) - - if valid - puts "Valid reference: #{ref_id} (#{ref_type}) at #{file_type}:#{ref_path}" if @options[:print_valid] - else - invalid_refs[file_type] ||= {} - invalid_refs[file_type][ref_path] = { id: ref_id, type: ref_type } - end - end - end - - def display_reference_results(invalid_refs, registry) + def display_result(invalid_refs) if invalid_refs.empty? puts "All references are valid!" return end puts "Found invalid references:" - - # Display registry contents if debug_registry is enabled - # This is needed for the failing test - if @options[:debug_registry] - puts "\nRegistry contents:" - registry.each do |type, ids| - next if ids.empty? - - puts " #{type}:" - ids.each do |id, location| - puts " #{id}: {type: #{type.sub('s', - '')}, source: #{location}}" - end - end - end invalid_refs.each do |file, refs| puts " #{file}:" refs.each do |path, ref| puts " #{path} => '#{ref[:id]}' (#{ref[:type]})" - - # Suggest corrections - next unless registry.key?(ref[:ref_type]) - - similar_ids = Unitsdb::Utils.find_similar_ids(ref[:id], - registry[ref[:ref_type]].keys) - if similar_ids.any? - puts " Did you mean one of these?" - similar_ids.each { |id| puts " - #{id}" } - end end end end diff --git a/lib/unitsdb/commands/validate/si_references.rb b/lib/unitsdb/commands/validate/si_references.rb index ae715dd..ca36d63 100644 --- a/lib/unitsdb/commands/validate/si_references.rb +++ b/lib/unitsdb/commands/validate/si_references.rb @@ -3,16 +3,17 @@ module Unitsdb module Commands module Validate + # `unitsdb validate si_references`. Checks that no SI + # digital-framework reference URI is used by more than one + # Unit, Quantity, or Prefix. class SiReferences < Unitsdb::Commands::Base + AUTHORITY = "si-digital-framework" + ENTITY_COLLECTIONS = %i[units quantities prefixes].freeze + def run - # Load the database db = load_database(@options[:database]) - - # Check for duplicate SI references - duplicates = check_si_references(db) - - # Display results - display_duplicate_results(duplicates) + duplicates = scan(db) + display(duplicates) rescue Unitsdb::Errors::DatabaseError => e raise Unitsdb::Errors::ValidationError, "Failed to validate SI references: #{e.message}" @@ -20,86 +21,49 @@ def run private - def check_si_references(db) - duplicates = {} - - # Check units - check_entity_si_references(db.units, "units", duplicates) - - # Check quantities - check_entity_si_references(db.quantities, "quantities", duplicates) - - # Check prefixes - check_entity_si_references(db.prefixes, "prefixes", duplicates) - - duplicates + def scan(db) + {}.tap do |duplicates| + ENTITY_COLLECTIONS.each do |name| + dup = scan_collection(db.collection(name)) + duplicates[name.to_s] = dup unless dup.empty? + end + end end - def check_entity_si_references(entities, entity_type, duplicates) - # Track SI references by URI - si_refs = {} - + def scan_collection(entities) + by_uri = {} entities.each_with_index do |entity, index| - # Skip if no references - next unless entity.respond_to?(:references) && entity.references + refs = entity.references || [] + refs.each do |ref| + next unless ref.authority == AUTHORITY - # Check each reference - entity.references.each do |ref| - # Only interested in si-digital-framework references - next unless ref.authority == "si-digital-framework" - - # Get entity info for display - entity_id = if entity.respond_to?(:identifiers) && entity.identifiers&.first.respond_to?(:id) - entity.identifiers.first.id - else - entity.short - end - - # Track this reference - si_refs[ref.uri] ||= [] - si_refs[ref.uri] << { - entity_id: entity_id, - entity_name: entity.respond_to?(:names) ? entity.names.first : entity.short, + (by_uri[ref.uri] ||= []) << { + entity_id: entity.identifiers.first&.id || entity.short, + entity_name: entity.names.first || entity.short, index: index, } end end - - # Find duplicates (URIs with more than one entity) - si_refs.each do |uri, entities| - next unless entities.size > 1 - - # Record this duplicate - duplicates[entity_type] ||= {} - duplicates[entity_type][uri] = entities - end + by_uri.reject { |_, refs| refs.size == 1 } end - def display_duplicate_results(duplicates) + def display(duplicates) if duplicates.empty? - puts "No duplicate SI references found! Each SI reference URI is used by at most one entity of each type." + puts "No duplicate SI references found! " \ + "Each SI reference URI is used by at most one entity of each type." return end puts "Found duplicate SI references:" - duplicates.each do |entity_type, uri_duplicates| puts "\n #{entity_type.capitalize}:" - - uri_duplicates.each do |uri, entities| + uri_duplicates.each do |uri, refs| puts " SI URI: #{uri}" - puts " Used by #{entities.size} entities:" - - entities.each do |entity| - puts " - #{entity[:entity_id]} (#{entity[:entity_name]}) at index #{entity[:index]}" - end + puts " Used by #{refs.size} entities:" + refs.each { |r| puts " - #{r[:entity_id]} (#{r[:entity_name]}) at index #{r[:index]}" } puts "" end end - - puts "\nEach SI digital framework reference should be used by at most one entity of each type." - puts "Please fix the duplicates by either removing the reference from all but one entity," - puts "or by updating the references to use different URIs appropriate for each entity." end end end diff --git a/lib/unitsdb/commands/validate/ucum_references.rb b/lib/unitsdb/commands/validate/ucum_references.rb index 4950edf..aa1130a 100644 --- a/lib/unitsdb/commands/validate/ucum_references.rb +++ b/lib/unitsdb/commands/validate/ucum_references.rb @@ -3,16 +3,16 @@ module Unitsdb module Commands module Validate + # `unitsdb validate ucum_references`. Checks that no UCUM + # reference code is used by more than one Unit or Prefix. class UcumReferences < Unitsdb::Commands::Base + AUTHORITY = "ucum" + ENTITY_COLLECTIONS = %i[units prefixes].freeze + def run - # Load the database db = load_database(@options[:database]) - - # Check for duplicate UCUM references - duplicates = check_ucum_references(db) - - # Display results - display_duplicate_results(duplicates) + duplicates = scan(db) + display(duplicates) rescue Unitsdb::Errors::DatabaseError => e raise Unitsdb::Errors::ValidationError, "Failed to validate UCUM references: #{e.message}" @@ -20,84 +20,50 @@ def run private - def check_ucum_references(db) - duplicates = {} - - # Check units - check_entity_ucum_references(db.units, "units", duplicates) - - # Check prefixes - check_entity_ucum_references(db.prefixes, "prefixes", duplicates) - - duplicates + def scan(db) + {}.tap do |duplicates| + ENTITY_COLLECTIONS.each do |name| + collection = db.collection(name) || [] + dup = scan_collection(collection) + duplicates[name.to_s] = dup unless dup.empty? + end + end end - def check_entity_ucum_references(entities, entity_type, duplicates) - # Track UCUM references by code - ucum_refs = {} - + def scan_collection(entities) + by_code = {} entities.each_with_index do |entity, index| - # Skip if no external references - next unless entity.respond_to?(:external_references) && entity.external_references + refs = entity.references || [] + refs.each do |ref| + next unless ref.authority == AUTHORITY - # Check each external reference - entity.external_references.each do |ref| - # Only interested in ucum references - next unless ref.authority == "ucum" - - # Get entity info for display - entity_id = entity.respond_to?(:id) ? entity.id : entity.short - entity_name = if entity.respond_to?(:names) && entity.names&.first - entity.names.first.respond_to?(:name) ? entity.names.first.name : entity.names.first - else - entity.short - end - - # Track this reference - ucum_refs[ref.code] ||= [] - ucum_refs[ref.code] << { - entity_id: entity_id, - entity_name: entity_name, + (by_code[ref.uri] ||= []) << { + entity_id: entity.identifiers.first&.id || entity.short, + entity_name: entity.names.first&.value || entity.short, index: index, } end end - - # Find duplicates (codes with more than one entity) - ucum_refs.each do |code, entities| - next unless entities.size > 1 - - # Record this duplicate - duplicates[entity_type] ||= {} - duplicates[entity_type][code] = entities - end + by_code.reject { |_, refs| refs.size == 1 } end - def display_duplicate_results(duplicates) + def display(duplicates) if duplicates.empty? - puts "No duplicate UCUM references found! Each UCUM reference code is used by at most one entity of each type." + puts "No duplicate UCUM references found! " \ + "Each UCUM reference code is used by at most one entity of each type." return end puts "Found duplicate UCUM references:" - duplicates.each do |entity_type, code_duplicates| puts "\n #{entity_type.capitalize}:" - - code_duplicates.each do |code, entities| + code_duplicates.each do |code, refs| puts " UCUM Code: #{code}" - puts " Used by #{entities.size} entities:" - - entities.each do |entity| - puts " - #{entity[:entity_id]} (#{entity[:entity_name]}) at index #{entity[:index]}" - end + puts " Used by #{refs.size} entities:" + refs.each { |r| puts " - #{r[:entity_id]} (#{r[:entity_name]}) at index #{r[:index]}" } puts "" end end - - puts "\nEach UCUM reference should be used by at most one entity of each type." - puts "Please fix the duplicates by either removing the reference from all but one entity," - puts "or by updating the references to use different codes appropriate for each entity." end end end diff --git a/lib/unitsdb/config.rb b/lib/unitsdb/config.rb index 97c88d9..0e6cc28 100644 --- a/lib/unitsdb/config.rb +++ b/lib/unitsdb/config.rb @@ -5,10 +5,18 @@ class Config CONTEXT_ID = :unitsdb_v2 class << self + # The currently-active default context id. Config-populated + # contexts are created on demand under this id. def context_id @context_id ||= CONTEXT_ID end + # --------------------------------------------------------------- + # Model registry + # --------------------------------------------------------------- + + # Register a model class under `id`. Single registration API; + # `models=` is a thin enumerator over this. def register_model(klass, id:) registered_models[id.to_sym] = klass klass @@ -18,28 +26,34 @@ def registered_models @registered_models ||= {} end - def models - @models ||= {} - end - + # Bulk-register models from a hash. Reads back through + # `register_model` so there is a single source of truth. def models=(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) + user_models.each do |id, klass| + register_model(klass, id: id.to_sym) end - - models.merge!(normalized_models) end + # Look up a registered model by id. Kept as a stable public + # API for downstream gems (e.g. unitsml) that previously used + # the Configuration module. def model_for(model_name) - model_id = model_name.to_sym - models[model_id] || registered_models[model_id] + registered_models[model_name.to_sym] end - def register(id = context_id) - explicit_registers[id.to_sym] + # --------------------------------------------------------------- + # Lutaml register bridge (opt-in) + # --------------------------------------------------------------- + + # Look up the Lutaml::Model::Register id (or nil) that was + # explicitly created for `context_id` via `populate_register`. + def register_id_for(context_id = context_id()) + explicit_registers[context_id.to_sym] end + # Create a Lutaml::Model::Register for `id`, enabling + # `from_hash(register: id)` deserialization. Power-user API — + # most callers want `populate_context` only. def populate_register(id: context_id, fallback_to: [:default], substitutions: []) register_id = id.to_sym context(register_id) @@ -57,6 +71,14 @@ def populate_register(id: context_id, fallback_to: [:default], substitutions: [] explicit_registers[register_id] = Lutaml::Model::GlobalRegister.register(model_register) end + def explicit_registers + @explicit_registers ||= {} + end + + # --------------------------------------------------------------- + # Context lifecycle + # --------------------------------------------------------------- + def find_context(id) Lutaml::Model::GlobalContext.context(id.to_sym) end @@ -65,25 +87,40 @@ 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) + # Return the context for `id`, creating it via `populate_context` + # when missing. Non-destructive: existing contexts (whether + # Config-created or externally-managed) are returned as-is. + # Use `populate_context` directly to force-rebuild. + def context(id = context_id()) + find_context(id) || populate_context(id: id) end - def populate_context(id: context_id, fallback_to: [:default], substitutions: []) + # Force-create a context under `id`, replacing any prior + # context (owned or external). + 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( + Lutaml::Model::GlobalContext.create_context( substitutions: resolve_substitutions(substitutions, **opts), **opts, ) - mark_populated!(id) - context end + # Convenience: ensure the default context exists. Idempotent. + # Used as the single bootstrap site for `Unitsdb.database` and + # `Database.from_db`. + def ensure_default_context! + return if find_context(context_id) + + populate_context(id: context_id) + end + + # --------------------------------------------------------------- + # Substitution resolution + # --------------------------------------------------------------- + def resolve_substitutions(substitutions, registry:, fallback_to:, id:) resolution_context = Lutaml::Model::TypeContext.derived( id: "#{id}_substitution_resolution", @@ -109,22 +146,39 @@ def resolve_substitution_type(value, resolution_context) end def build_registry + Unitsdb.eager_load_models! 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) + # --------------------------------------------------------------- + # Test support — snapshot/restore/isolate + # --------------------------------------------------------------- + + def capture_state + { + registered_models: registered_models.dup, + explicit_registers: explicit_registers.dup, + context_id: @context_id, + } end - def mark_populated!(context_id) - @populated_for ||= {} - @populated_for[context_id.to_sym] = true + def restore_state(snapshot) + @registered_models = snapshot[:registered_models] + @explicit_registers = snapshot[:explicit_registers] + @context_id = snapshot[:context_id] end - def explicit_registers - @explicit_registers ||= {} + # Run a block against a fresh Lutaml global context, then + # restore Config state so subsequent specs see bundled defaults. + def with_isolated_config + snapshot = capture_state + Lutaml::Model::GlobalContext.reset! + yield + ensure + restore_state(snapshot) if snapshot + Lutaml::Model::GlobalContext.reset! end end end diff --git a/lib/unitsdb/database.rb b/lib/unitsdb/database.rb index 8218736..43fb340 100644 --- a/lib/unitsdb/database.rb +++ b/lib/unitsdb/database.rb @@ -2,16 +2,15 @@ module Unitsdb class Database < Lutaml::Model::Serializable - # model Config.model_for(:units) + autoload :ReferenceValidator, "unitsdb/database/reference_validator" + autoload :UniquenessValidator, "unitsdb/database/uniqueness_validator" + autoload :Loader, "unitsdb/database/loader" - 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" + COLLECTIONS = Loader::DATABASE_FILES.keys.map(&:to_sym).freeze + + # Collections whose entities carry a `symbols` attribute. + # Used by symbol-based search/match to narrow iteration. + SYMBOL_COLLECTIONS = %i[units prefixes].freeze attribute :schema_version, :string attribute :version, :string @@ -21,16 +20,47 @@ class Database < Lutaml::Model::Serializable attribute :dimensions, Dimension, collection: true attribute :unit_systems, UnitSystem, collection: true + # Resolve a collection name (String or Symbol) to its typed Array. + # Validates against COLLECTIONS so caller-supplied names can never + # dispatch to arbitrary methods (e.g. `schema_version`, `to_yaml`). + def collection(name) + sym = name.to_sym + unless COLLECTIONS.include?(sym) + raise ArgumentError, "unknown collection: #{name.inspect}" + end + + public_send(sym) + end + + # Build an empty Database with `entities` partitioned into their + # typed collections. Used by CLI commands that need to serialize + # a subset of search results. + def self.empty_for_results(entities) + Database.new.tap do |db| + COLLECTIONS.each do |name| + klass = collection_element_class(name) + db.public_send("#{name}=", entities.grep(klass)) + end + end + end + + class << self + private + + # Map a collection symbol to the entity class it holds. + # Derived from the Lutaml attribute declaration. + def collection_element_class(name) + attributes.fetch(name.to_s).type + end + end + # Find an entity by its specific identifier and type # @param id [String] the identifier value to search for # @param type [String, Symbol] the entity type (units, prefixes, quantities, etc.) # @return [Object, nil] the first entity with matching identifier or nil if not found def find_by_type(id:, type:) - collection = send(type.to_s) - collection.find do |entity| - entity.identifiers&.any? do |identifier| - identifier.id == id - end + collection(type).find do |entity| + entity.identifiers.any? { |identifier| identifier.id == id } end end @@ -39,82 +69,36 @@ def find_by_type(id:, type:) # @param type [String, nil] optional identifier type to match # @return [Object, nil] the first entity with matching identifier or nil if not found def get_by_id(id:, type: nil) - %w[units prefixes quantities dimensions - unit_systems].each do |collection_name| - next unless respond_to?(collection_name) - - collection = send(collection_name) - entity = collection.find do |e| - e.identifiers&.any? do |identifier| + COLLECTIONS.each do |name| + entity = collection(name).find do |e| + e.identifiers.any? do |identifier| identifier.id == id && (type.nil? || identifier.type == type) end end - return entity if entity end nil end - # Search for entities containing the given text in identifiers, names, or short description + # Search for entities containing the given text in identifiers, + # names, or short description. # @param params [Hash] search parameters # @option params [String] :text The text to search for # @option params [String, Symbol, nil] :type Optional entity type to limit search scope # @return [Array] all entities matching the search criteria def search(params = {}) text = params[:text] - type = params[:type] - return [] unless text - results = [] - - # Define which collections to search based on type parameter - collections = if type - [type.to_s] - else - %w[units prefixes quantities - dimensions unit_systems] - end - - collections.each do |collection_name| - next unless respond_to?(collection_name) + needle = text.downcase + scope = scope_for(params[:type], COLLECTIONS) - collection = send(collection_name) - collection.each do |entity| - # Search in identifiers - if entity.identifiers&.any? do |identifier| - identifier.id.to_s.downcase.include?(text.downcase) - end - results << entity - next - end - - # Search in names (if the entity has names) - if entity.respond_to?(:names) && entity.names && entity.names.any? do |name| - name.value.to_s.downcase.include?(text.downcase) - end - results << entity - next - end - - # Search in short description - if entity.respond_to?(:short) && entity.short && - entity.short.to_s.downcase.include?(text.downcase) - results << entity - next - end - - # Special case for prefix name (prefixes don't have names array) - next unless collection_name == "prefixes" && entity.respond_to?(:name) && - entity.name.to_s.downcase.include?(text.downcase) - - results << entity - next + scope.each_with_object([]) do |name, results| + collection(name).each do |entity| + results << entity if matches_text?(entity, needle) end end - - results end # Find entities by symbol @@ -124,38 +108,16 @@ def search(params = {}) def find_by_symbol(symbol, entity_type = nil) return [] unless symbol - results = [] - - # Symbol search only applies to units and prefixes - collections = entity_type ? [entity_type.to_s] : %w[units prefixes] - - collections.each do |collection_name| - next unless respond_to?(collection_name) && %w[units - prefixes].include?(collection_name) - - collection = send(collection_name) - collection.each do |entity| - if collection_name == "units" && entity.respond_to?(:symbols) && entity.symbols - # Units can have multiple symbols - matches = entity.symbols.any? do |sym| - sym.respond_to?(:ascii) && sym.ascii && - sym.ascii.downcase == symbol.downcase - end - - results << entity if matches - elsif collection_name == "prefixes" && entity.respond_to?(:symbols) && entity.symbols - # Prefixes have multiple symbols in 2.0.0 - matches = entity.symbols.any? do |sym| - sym.respond_to?(:ascii) && sym.ascii && - sym.ascii.downcase == symbol.downcase - end - - results << entity if matches + needle = symbol.downcase + scope = scope_for(entity_type, SYMBOL_COLLECTIONS) + + scope.each_with_object([]) do |name, results| + collection(name).each do |entity| + results << entity if entity.symbols.any? do |sym| + sym.ascii.to_s.downcase == needle end end end - - results end # Match entities by name, short, or symbol with different match types @@ -166,530 +128,108 @@ def find_by_symbol(symbol, entity_type = nil) # @return [Hash] matches grouped by match type (exact, symbol_match) with match details def match_entities(params = {}) value = params[:value] - match_type = params[:match_type]&.to_s || "exact" - entity_type = params[:entity_type] - return {} unless value - result = { - exact: [], - symbol_match: [], - } - - # Define collections to search based on entity_type parameter - collections = if entity_type - [entity_type.to_s] - else - %w[units prefixes - quantities dimensions unit_systems] - end - - collections.each do |collection_name| - next unless respond_to?(collection_name) - - collection = send(collection_name) + match_type = params[:match_type]&.to_s || "exact" + result = { exact: [], symbol_match: [] } - collection.each do |entity| - # For exact matches - look at short and names + scope_for(params[:entity_type], COLLECTIONS).each do |name| + collection(name).each do |entity| if %w[exact all].include?(match_type) - # Match by short - if entity.respond_to?(:short) && entity.short && - entity.short.downcase == value.downcase - result[:exact] << { - entity: entity, - match_desc: "short_to_name", - details: "UnitsDB short '#{entity.short}' matches '#{value}'", - } - next - end - - # Match by names - if entity.respond_to?(:names) && entity.names - matching_name = entity.names.find do |name| - name.value.to_s.downcase == value.downcase - end - if matching_name - result[:exact] << { - entity: entity, - match_desc: "name_to_name", - details: "UnitsDB name '#{matching_name.value}' (#{matching_name.lang}) matches '#{value}'", - } - next - end - end + match_exact(entity, value, result) end + next unless %w[symbol all].include?(match_type) && SYMBOL_COLLECTIONS.include?(name) - # For symbol matches - only applicable to units and prefixes - if %w[symbol all].include?(match_type) && - %w[units prefixes].include?(collection_name) - if collection_name == "units" && entity.respond_to?(:symbols) && entity.symbols - # Units can have multiple symbols - matching_symbol = entity.symbols.find do |sym| - sym.respond_to?(:ascii) && sym.ascii && - sym.ascii.downcase == value.downcase - end - - if matching_symbol - result[:symbol_match] << { - entity: entity, - match_desc: "symbol_match", - details: "UnitsDB symbol '#{matching_symbol.ascii}' matches '#{value}'", - } - end - elsif collection_name == "prefixes" && entity.respond_to?(:symbols) && entity.symbols - # Prefixes have multiple symbols in 2.0.0 - matching_symbol = entity.symbols.find do |sym| - sym.respond_to?(:ascii) && sym.ascii && - sym.ascii.downcase == value.downcase - end - - if matching_symbol - result[:symbol_match] << { - entity: entity, - match_desc: "symbol_match", - details: "UnitsDB symbol '#{matching_symbol.ascii}' matches '#{value}'", - } - end - end - end + match_symbol(entity, value, result) end end - # Remove empty categories result.delete_if { |_, v| v.empty? } - result end - # Checks for uniqueness of identifiers and short names + # Checks for uniqueness of identifiers and short names. + # Delegates to UniquenessValidator. def validate_uniqueness - results = { - short: {}, - id: {}, - } - - # Validate short names for applicable collections - validate_shorts(units, "units", results) - validate_shorts(dimensions, "dimensions", results) - validate_shorts(unit_systems, "unit_systems", results) - - # Validate identifiers for all collections - validate_identifiers(units, "units", results) - validate_identifiers(prefixes, "prefixes", results) - validate_identifiers(quantities, "quantities", results) - validate_identifiers(dimensions, "dimensions", results) - validate_identifiers(unit_systems, "unit_systems", results) - - results + UniquenessValidator.validate(self) end - # Validates references between entities + # Validates references between entities. Delegates to ReferenceValidator. def validate_references - invalid_refs = {} - - # Build registry of all valid IDs first - registry = build_id_registry - - # Check various reference types - check_dimension_references(registry, invalid_refs) - check_unit_system_references(registry, invalid_refs) - check_quantity_references(registry, invalid_refs) - check_root_unit_references(registry, invalid_refs) - - invalid_refs - end - - 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 - - db_path = File.expand_path(dir_path.to_s) - unless Dir.exist?(db_path) - raise Errors::DatabaseNotFoundError, - "Database directory not found: #{db_path}" - end - - missing_files = DATABASE_FILES.values.reject do |filename| - File.exist?(File.join(db_path, filename)) - end - - if missing_files.any? - raise Errors::DatabaseFileNotFoundError, - "Missing required database files: #{missing_files.join(', ')}" - end - - 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 - - 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 structure in #{filename}: expected a mapping" - end - - 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 - - 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 #{filename}" - end - - unless versions.values.uniq.size == 1 - raise Errors::VersionMismatchError, - "Version mismatch in database files: #{versions.inspect}" - end - - version = versions.values.first - unless version == SUPPORTED_SCHEMA_VERSION - raise Errors::UnsupportedVersionError, - "Unsupported database version: #{version}. Only version #{SUPPORTED_SCHEMA_VERSION} is supported." - end - - version + ReferenceValidator.validate(self) 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 - - 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 - # Helper methods for uniqueness validation - def validate_shorts(collection, type, results) - shorts = {} - - collection.each_with_index do |item, index| - next unless item.respond_to?(:short) && item.short + # Resolve a caller-supplied type to a list of collection symbols, + # validating against `allowed`. Nil means "all allowed". + def scope_for(type, allowed) + return allowed.dup unless type - (shorts[item.short] ||= []) << "index:#{index}" - end - - # Add to results if duplicates found - shorts.each do |short, paths| - next unless paths.size > 1 + sym = type.to_sym + raise ArgumentError, "unknown collection: #{type.inspect}" unless allowed.include?(sym) - (results[:short][type] ||= {})[short] = paths - end + [sym] end - def validate_identifiers(collection, type, results) - ids = {} - - collection.each_with_index do |item, index| - next unless item.respond_to?(:identifiers) - - # Process identifiers array for this item - item.identifiers.each_with_index do |identifier, id_index| - next unless identifier.respond_to?(:id) && identifier.id - - id_key = identifier.id - loc = "index:#{index}:identifiers[#{id_index}]" - (ids[id_key] ||= []) << loc - end - end - - # Add duplicates to results - ids.each do |id, paths| - unique_paths = paths.uniq - next unless unique_paths.size > 1 - - (results[:id][type] ||= {})[id] = unique_paths - end + def matches_text?(entity, needle) + entity.identifiers.any? { |i| i.id.to_s.downcase.include?(needle) } || + entity.names.any? { |n| n.value.to_s.downcase.include?(needle) } || + entity.short.to_s.downcase.include?(needle) end - # Helper methods for reference validation - def build_id_registry - registry = {} - - # Add unit identifiers - registry["units"] = {} - units.each_with_index do |unit, index| - next unless unit.respond_to?(:identifiers) - - unit.identifiers.each do |identifier| - next unless identifier.id && identifier.type - - # Add composite key (type:id) - composite_key = "#{identifier.type}:#{identifier.id}" - registry["units"][composite_key] = "index:#{index}" - - # Also add just the ID for backward compatibility - registry["units"][identifier.id] = "index:#{index}" - end - end - - # Add dimension identifiers - registry["dimensions"] = {} - dimensions.each_with_index do |dimension, index| - next unless dimension.respond_to?(:identifiers) - - dimension.identifiers.each do |identifier| - next unless identifier.id && identifier.type - - composite_key = "#{identifier.type}:#{identifier.id}" - registry["dimensions"][composite_key] = "index:#{index}" - registry["dimensions"][identifier.id] = "index:#{index}" - end - - # Also track dimensions by short name - if dimension.respond_to?(:short) && dimension.short - registry["dimensions_short"] ||= {} - registry["dimensions_short"][dimension.short] = "index:#{index}" - end - end - - # Add quantity identifiers - registry["quantities"] = {} - quantities.each_with_index do |quantity, index| - next unless quantity.respond_to?(:identifiers) - - quantity.identifiers.each do |identifier| - next unless identifier.id && identifier.type - - composite_key = "#{identifier.type}:#{identifier.id}" - registry["quantities"][composite_key] = "index:#{index}" - registry["quantities"][identifier.id] = "index:#{index}" - end - end - - # Add prefix identifiers - registry["prefixes"] = {} - prefixes.each_with_index do |prefix, index| - next unless prefix.respond_to?(:identifiers) - - prefix.identifiers.each do |identifier| - next unless identifier.id && identifier.type - - composite_key = "#{identifier.type}:#{identifier.id}" - registry["prefixes"][composite_key] = "index:#{index}" - registry["prefixes"][identifier.id] = "index:#{index}" - end + def match_exact(entity, value, result) + if entity.short && entity.short.downcase == value.downcase + result[:exact] << { + entity: entity, + match_desc: "short_to_name", + details: "UnitsDB short '#{entity.short}' matches '#{value}'", + } + return end - # Add unit system identifiers - registry["unit_systems"] = {} - unit_systems.each_with_index do |unit_system, index| - next unless unit_system.respond_to?(:identifiers) - - unit_system.identifiers.each do |identifier| - next unless identifier.id && identifier.type - - composite_key = "#{identifier.type}:#{identifier.id}" - registry["unit_systems"][composite_key] = "index:#{index}" - registry["unit_systems"][identifier.id] = "index:#{index}" - end - - # Also track unit systems by short name - if unit_system.respond_to?(:short) && unit_system.short - registry["unit_systems_short"] ||= {} - registry["unit_systems_short"][unit_system.short] = "index:#{index}" - end + matching_name = entity.names.find do |name| + name.value.to_s.downcase == value.downcase end + return unless matching_name - registry - end - - def check_dimension_references(registry, invalid_refs) - dimensions.each_with_index do |dimension, index| - next unless dimension.respond_to?(:dimension_reference) && dimension.dimension_reference - - ref_id = dimension.dimension_reference - ref_type = "dimensions" - ref_path = "dimensions:index:#{index}:dimension_reference" - - validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs, - "dimensions") - end + result[:exact] << { + entity: entity, + match_desc: "name_to_name", + details: "UnitsDB name '#{matching_name.value}' (#{matching_name.lang}) matches '#{value}'", + } end - def check_unit_system_references(registry, invalid_refs) - units.each_with_index do |unit, index| - next unless unit.respond_to?(:unit_system_reference) && unit.unit_system_reference - - unit.unit_system_reference.each_with_index do |ref_id, idx| - ref_type = "unit_systems" - ref_path = "units:index:#{index}:unit_system_reference[#{idx}]" - - validate_reference(ref_id, ref_type, ref_path, registry, - invalid_refs, "units") - end + def match_symbol(entity, value, result) + matching_symbol = entity.symbols.find do |sym| + sym.ascii.to_s.downcase == value.downcase end - end - - def check_quantity_references(registry, invalid_refs) - units.each_with_index do |unit, index| - next unless unit.respond_to?(:quantity_references) && unit.quantity_references + return unless matching_symbol - unit.quantity_references.each_with_index do |ref_id, idx| - ref_type = "quantities" - ref_path = "units:index:#{index}:quantity_references[#{idx}]" - - validate_reference(ref_id, ref_type, ref_path, registry, - invalid_refs, "units") - end - end - end - - def check_root_unit_references(registry, invalid_refs) - units.each_with_index do |unit, index| - next unless unit.respond_to?(:root_units) && unit.root_units - - unit.root_units.each_with_index do |root_unit, idx| - next unless root_unit.respond_to?(:unit_reference) && root_unit.unit_reference - - # Check unit reference - ref_id = root_unit.unit_reference - ref_type = "units" - ref_path = "units:index:#{index}:root_units.#{idx}.unit_reference" - - validate_reference(ref_id, ref_type, ref_path, registry, - invalid_refs, "units") - - # Check prefix reference if present - next unless root_unit.respond_to?(:prefix_reference) && root_unit.prefix_reference - - ref_id = root_unit.prefix_reference - ref_type = "prefixes" - ref_path = "units:index:#{index}:root_units.#{idx}.prefix_reference" - - validate_reference(ref_id, ref_type, ref_path, registry, - invalid_refs, "units") - end - end + result[:symbol_match] << { + entity: entity, + match_desc: "symbol_match", + details: "UnitsDB symbol '#{matching_symbol.ascii}' matches '#{value}'", + } end - 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 - type = ref_id.type - composite_key = "#{type}:#{id}" - - # Try multiple lookup strategies - valid = false - - # 1. Try exact composite key match - valid = true if registry.key?(ref_type) && registry[ref_type].key?(composite_key) - - # 2. Try just ID match if composite didn't work - valid = true if !valid && registry.key?(ref_type) && registry[ref_type].key?(id) - - # 3. Try alternate ID formats for unit systems (e.g., SI_base vs si-base) - if !valid && type == "unitsml" && ref_type == "unit_systems" && registry.key?(ref_type) && ( - registry[ref_type].keys.any? { |k| k.end_with?(":#{id}") } || - registry[ref_type].keys.any? do |k| - k.end_with?(":SI_#{id.sub('si-', '')}") - end || - registry[ref_type].keys.any? do |k| - k.end_with?(":non-SI_#{id.sub('nonsi-', '')}") - end - ) - # Special handling for unit_systems between unitsml and nist types - valid = true - end + class << self + # Load every YAML file under `dir_path` and deserialize into a + # Database instance scoped to `context`. The default context is + # auto-created via `Config.ensure_default_context!`; custom + # contexts must be created by the caller first. + def from_db(dir_path, context: Unitsdb::Config.context_id) + context_id = context.to_sym + Unitsdb::Config.ensure_default_context! if context_id == Unitsdb::Config.context_id - unless valid - invalid_refs[file_type] ||= {} - invalid_refs[file_type][ref_path] = - { id: id, type: type, ref_type: ref_type } - end - # Handle references that are objects with id and type in a hash - elsif ref_id.is_a?(Hash) && ref_id.key?("id") && ref_id.key?("type") - id = ref_id["id"] - type = ref_id["type"] - composite_key = "#{type}:#{id}" - - # Try multiple lookup strategies - valid = false - - # 1. Try exact composite key match - valid = true if registry.key?(ref_type) && registry[ref_type].key?(composite_key) - - # 2. Try just ID match if composite didn't work - valid = true if !valid && registry.key?(ref_type) && registry[ref_type].key?(id) - - # 3. Try alternate ID formats for unit systems (e.g., SI_base vs si-base) - if !valid && type == "unitsml" && ref_type == "unit_systems" && registry.key?(ref_type) && ( - registry[ref_type].keys.any? { |k| k.end_with?(":#{id}") } || - registry[ref_type].keys.any? do |k| - k.end_with?(":SI_#{id.sub('si-', '')}") - end || - registry[ref_type].keys.any? do |k| - k.end_with?(":non-SI_#{id.sub('nonsi-', '')}") - end - ) - # Special handling for unit_systems between unitsml and nist types - valid = true - end - - unless valid - invalid_refs[file_type] ||= {} - invalid_refs[file_type][ref_path] = - { id: id, type: type, ref_type: ref_type } - end - else - # Handle plain string references (legacy format) - valid = registry.key?(ref_type) && registry[ref_type].key?(ref_id) + combined_hash = Loader.load(dir_path) - unless valid - invalid_refs[file_type] ||= {} - invalid_refs[file_type][ref_path] = { id: ref_id, type: ref_type } + Lutaml::Model::GlobalContext.with_context(context_id) do + if Unitsdb::Config.register_id_for(context_id) + from_hash(combined_hash, register: context_id) + else + from_hash(combined_hash) + end end end end diff --git a/lib/unitsdb/database/loader.rb b/lib/unitsdb/database/loader.rb new file mode 100644 index 0000000..df95100 --- /dev/null +++ b/lib/unitsdb/database/loader.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "yaml" + +module Unitsdb + class Database + # Filesystem and YAML-schema layer of Database. Reads the + # collection YAML files from a directory, validates their schema + # versions agree, and returns a combined hash ready for + # Lutaml::Model deserialization. Knows nothing about contexts or + # registers — that's `Database.from_db`'s job. + class Loader + 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" + + def self.load(dir_path) + new(dir_path).load + end + + def initialize(dir_path) + @dir_path = File.expand_path(dir_path.to_s) + end + + # Read every YAML file under @dir_path, validate schema versions, + # and return a combined hash keyed by collection name. + def load + verify_directory! + documents = read_documents + schema_version = validate_schema_versions!(documents) + build_database_hash(documents, schema_version) + end + + private + + def verify_directory! + unless Dir.exist?(@dir_path) + raise Errors::DatabaseNotFoundError, + "Database directory not found: #{@dir_path}" + end + + missing = DATABASE_FILES.values.reject do |filename| + File.exist?(File.join(@dir_path, filename)) + end + return if missing.empty? + + raise Errors::DatabaseFileNotFoundError, + "Missing required database files: #{missing.join(', ')}" + end + + def read_documents + if ENV["UNITSDB_DEBUG"] + puts "[UnitsDB] Loading YAML files from directory: #{@dir_path}" + end + DATABASE_FILES.transform_values do |filename| + path = File.join(@dir_path, filename) + puts " - #{path}" if ENV["UNITSDB_DEBUG"] + read_yaml(path, filename) + end + end + + def read_yaml(path, filename) + document = YAML.safe_load_file(path) + unless document.is_a?(Hash) + raise Errors::DatabaseFileInvalidError, + "Invalid YAML structure in #{filename}: expected a mapping" + end + + 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 + + def 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 #{filename}" + end + + unless versions.values.uniq.size == 1 + raise Errors::VersionMismatchError, + "Version mismatch in database files: #{versions.inspect}" + end + + version = versions.values.first + return version if version == SUPPORTED_SCHEMA_VERSION + + raise Errors::UnsupportedVersionError, + "Unsupported database version: #{version}. " \ + "Only version #{SUPPORTED_SCHEMA_VERSION} is supported." + end + + def 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 + + def 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 + end + + # Backwards-compat aliases — external callers (and the spec) read + # these constants off Database directly. + DATABASE_FILES = Loader::DATABASE_FILES + SUPPORTED_SCHEMA_VERSION = Loader::SUPPORTED_SCHEMA_VERSION + end +end diff --git a/lib/unitsdb/database/reference_validator.rb b/lib/unitsdb/database/reference_validator.rb new file mode 100644 index 0000000..8e08d3a --- /dev/null +++ b/lib/unitsdb/database/reference_validator.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +module Unitsdb + class Database + # Validates that every cross-entity reference in a Database points + # at an entity that actually exists. Single source of truth for + # both `Database#validate_references` and the + # `unitsdb validate references` CLI command. + # + # Composition: + # ReferenceValidator — public façade; returns a Result + # IdRegistry — builds {type => {key => path}} from db + # ReferenceChecker — walks each reference kind + # LookupStrategies — pluggable "is this ref valid?" predicates + class ReferenceValidator + # @return [Hash] empty if all refs valid; otherwise + # { file_type => { ref_path => { id:, type:, ref_type: } } } + Result = Struct.new(:invalid, keyword_init: true) do + def empty? + invalid.empty? + end + end + + def initialize(database) + @database = database + end + + def validate + registry = IdRegistry.build(@database) + checker = ReferenceChecker.new(registry, LookupStrategies::ALL) + Result.new(invalid: checker.check_all(@database)) + end + + # Convenience entry-point used by Database#validate_references. + def self.validate(database) + new(database).validate.invalid + end + end + + # Builds a {collection_type => {ref_key => path}} registry + # covering identifiers (composite-keyed and bare-id keyed) plus + # short-name lookups for dimensions and unit_systems. + class IdRegistry + def self.build(database) + new(database).build + end + + def initialize(database) + @database = database + end + + def build + registry = {} + Database::COLLECTIONS.each do |name| + registry[name.to_s] = {} + add_identifiers(registry, name) + end + add_short_names(registry, :dimensions, "dimensions_short") + add_short_names(registry, :unit_systems, "unit_systems_short") + registry + end + + private + + def add_identifiers(registry, collection_name) + collection = @database.collection(collection_name) + collection.each_with_index do |entity, index| + entity.identifiers.each do |identifier| + next unless identifier.id && identifier.type + + composite = "#{identifier.type}:#{identifier.id}" + registry[collection_name.to_s][composite] = "index:#{index}" + registry[collection_name.to_s][identifier.id] = "index:#{index}" + end + end + end + + def add_short_names(registry, collection_name, key) + registry[key.to_s] = {} + @database.collection(collection_name).each_with_index do |entity, idx| + next unless entity.short + + registry[key.to_s][entity.short] = "index:#{idx}" + end + end + end + + # Walks each reference kind in the Database and records invalid + # references via the supplied lookup strategies. + class ReferenceChecker + def initialize(registry, strategies) + @registry = registry + @strategies = strategies + end + + def check_all(database) + invalid = {} + check_unit_system_references(database, invalid) + check_quantity_references(database, invalid) + check_root_unit_references(database, invalid) + invalid + end + + private + + def check_unit_system_references(database, invalid) + database.units.each_with_index do |unit, index| + refs = unit.unit_system_reference + next unless refs + + refs.each_with_index do |ref, idx| + validate(ref, "unit_systems", "units", + "units:index:#{index}:unit_system_reference[#{idx}]", invalid) + end + end + end + + def check_quantity_references(database, invalid) + database.units.each_with_index do |unit, index| + refs = unit.quantity_references + next unless refs + + refs.each_with_index do |ref, idx| + validate(ref, "quantities", "units", + "units:index:#{index}:quantity_references[#{idx}]", invalid) + end + end + end + + def check_root_unit_references(database, invalid) + database.units.each_with_index do |unit, index| + refs = unit.root_units + next unless refs + + refs.each_with_index do |root_unit, idx| + if root_unit.unit_reference + validate(root_unit.unit_reference, "units", "units", + "units:index:#{index}:root_units.#{idx}.unit_reference", + invalid) + end + + next unless root_unit.prefix_reference + + validate(root_unit.prefix_reference, "prefixes", "units", + "units:index:#{index}:root_units.#{idx}.prefix_reference", + invalid) + end + end + end + + def validate(ref, ref_type, file_type, ref_path, invalid) + pair = Reference.destructure(ref) + valid = if pair + any_strategy_matches?(pair, ref_type) + else + @registry.key?(ref_type) && @registry[ref_type].key?(ref) + end + + return if valid + + invalid[file_type] ||= {} + invalid[file_type][ref_path] = if pair + { id: pair.id, type: pair.type, ref_type: ref_type } + else + { id: ref, type: ref_type } + end + end + + def any_strategy_matches?(pair, ref_type) + @strategies.any? { |s| s.call(pair, ref_type, @registry) } + end + end + + # A normalized reference pair. `id` and `type` are strings. + ReferencePair = Struct.new(:id, :type, keyword_init: true) + + # Coerces various reference shapes (Identifier instance, Hash with + # string keys, plain String) into a ReferencePair (or nil if the + # ref is a bare string with no type metadata). + module Reference + module_function + + def destructure(ref) + case ref + when Unitsdb::Identifier + ReferencePair.new(id: ref.id, type: ref.type) if ref.id && ref.type + when Hash + ReferencePair.new(id: ref["id"], type: ref["type"]) if ref["id"] && ref["type"] + end + end + end + + # Lookup strategies. Each is a proc that takes (pair, ref_type, + # registry) and returns true if the reference is valid under that + # strategy. Composed in ALL and tried in order by ReferenceChecker. + module LookupStrategies + # Exact "#{type}:#{id}" match. + COMPOSITE_KEY = ->(pair, ref_type, registry) do + registry.key?(ref_type) && registry[ref_type].key?("#{pair.type}:#{pair.id}") + end + + # Bare-id match (any type). For backward compatibility with + # databases that only stored the id without the type. + BARE_ID = ->(pair, ref_type, registry) do + registry.key?(ref_type) && registry[ref_type].key?(pair.id) + end + + # Unit-system alternate IDs. The UnitsML `unitsml` authority uses + # kebab-case (`si-base`) while NIST uses snake-case (`SI_base`). + # Accept either form when resolving unit_system references. + UNIT_SYSTEM_ALTERNATE = ->(pair, ref_type, registry) do + next false unless ref_type == "unit_systems" && pair.type == "unitsml" + next false unless registry.key?(ref_type) + + alternates = [] + alternates << pair.id + alternates << "SI_#{pair.id.sub('si-', '')}" if pair.id.start_with?("si-") + alternates << "non-SI_#{pair.id.sub('nonsi-', '')}" if pair.id.start_with?("nonsi-") + + keys = registry[ref_type].keys + alternates.any? { |alt| keys.any? { |k| k.end_with?(":#{alt}") } } + end + + ALL = [COMPOSITE_KEY, BARE_ID, UNIT_SYSTEM_ALTERNATE].freeze + end + end +end diff --git a/lib/unitsdb/database/uniqueness_validator.rb b/lib/unitsdb/database/uniqueness_validator.rb new file mode 100644 index 0000000..3b3bead --- /dev/null +++ b/lib/unitsdb/database/uniqueness_validator.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Unitsdb + class Database + # Validates that `short` names and identifier ids are unique + # within each collection. Encodes the policy of which + # collections participate in each check via two constants so + # the intent is explicit. + # + # UniquenessValidator.new(db).validate + # # => # + class UniquenessValidator + # Collections that carry a `short` attribute and must be + # unique-by-short within each collection. + SHORT_COLLECTIONS = %i[units dimensions unit_systems].freeze + + # Collections whose identifier `id` values must be unique. + IDENTIFIER_COLLECTIONS = Database::COLLECTIONS + + Result = Struct.new(:short, :id, keyword_init: true) do + def empty? + short.empty? && id.empty? + end + end + + def initialize(database) + @database = database + end + + def validate + Result.new( + short: scan_shorts, + id: scan_identifiers, + ) + end + + # Convenience entry-point used by Database#validate_uniqueness. + def self.validate(database) + result = new(database).validate + { + short: result.short, + id: result.id, + } + end + + private + + def scan_shorts + SHORT_COLLECTIONS.each_with_object({}) do |name, results| + by_value = {} + @database.collection(name).each_with_index do |entity, index| + next unless entity.short + + (by_value[entity.short] ||= []) << "index:#{index}" + end + + dups = by_value.reject { |_, paths| paths.size == 1 } + results[name.to_s] = dups unless dups.empty? + end + end + + def scan_identifiers + IDENTIFIER_COLLECTIONS.each_with_object({}) do |name, results| + by_id = {} + @database.collection(name).each_with_index do |entity, index| + entity.identifiers.each_with_index do |identifier, id_index| + next unless identifier.id + + loc = "index:#{index}:identifiers[#{id_index}]" + (by_id[identifier.id] ||= []) << loc + end + end + + dups = by_id.transform_values(&:uniq).reject { |_, paths| paths.size == 1 } + results[name.to_s] = dups unless dups.empty? + end + end + end + end +end diff --git a/lib/unitsdb/dimension.rb b/lib/unitsdb/dimension.rb index b4171b1..ab9a5a5 100644 --- a/lib/unitsdb/dimension.rb +++ b/lib/unitsdb/dimension.rb @@ -1,34 +1,7 @@ # frozen_string_literal: true -# NISTd1: -# length: -# power: 1 -# symbol: L -# dim_symbols: -# - id: "dim_L" -# ascii: "L" -# html: "𝖫" -# mathml: "L" -# latex: \ensuremath{\mathsf{L}} -# unicode: "𝖫" - -# NISTd9: -# -dimensionless: true -# -plane_angle: -# - dim_symbols: -# - - ascii: phi -# - html: "𝞅" -# - id: dim_phi -# - latex: "\\ensuremath{\\mathsf{\\phi}}" -# - mathml: "φ" -# - unicode: "\U0001D785" -# - power: 1 -# - symbol: phi - module Unitsdb class Dimension < Lutaml::Model::Serializable - # model Config.model_for(:dimension) - attribute :identifiers, Identifier, collection: true attribute :dimensionless, :boolean attribute :length, DimensionDetails diff --git a/lib/unitsdb/dimensions.rb b/lib/unitsdb/dimensions.rb index 1840dae..ef17ccb 100644 --- a/lib/unitsdb/dimensions.rb +++ b/lib/unitsdb/dimensions.rb @@ -2,8 +2,6 @@ module Unitsdb class Dimensions < Lutaml::Model::Serializable - # model Config.model_for(:dimensions) - attribute :schema_version, :string attribute :version, :string attribute :dimensions, Dimension, collection: true diff --git a/lib/unitsdb/prefix.rb b/lib/unitsdb/prefix.rb index b8899ea..1cc4684 100644 --- a/lib/unitsdb/prefix.rb +++ b/lib/unitsdb/prefix.rb @@ -1,20 +1,7 @@ # frozen_string_literal: true -# --- -# NISTp10_30: -# name: quetta -# symbol: -# ascii: Q -# html: Q -# latex: Q -# unicode: Q -# base: 10 -# power: 30 - module Unitsdb class Prefix < Lutaml::Model::Serializable - # model Config.model_for(:prefix) - attribute :identifiers, Identifier, collection: true attribute :names, LocalizedString, collection: true attribute :short, :string diff --git a/lib/unitsdb/prefixes.rb b/lib/unitsdb/prefixes.rb index 9a54767..ec3d246 100644 --- a/lib/unitsdb/prefixes.rb +++ b/lib/unitsdb/prefixes.rb @@ -13,8 +13,6 @@ module Unitsdb class Prefixes < Lutaml::Model::Serializable - # model Config.model_for(:prefixes) - attribute :schema_version, :string attribute :version, :string attribute :prefixes, Prefix, collection: true diff --git a/lib/unitsdb/quantities.rb b/lib/unitsdb/quantities.rb index 84ecf71..cea581f 100644 --- a/lib/unitsdb/quantities.rb +++ b/lib/unitsdb/quantities.rb @@ -2,7 +2,6 @@ module Unitsdb class Quantities < Lutaml::Model::Serializable - # model Config.model_for(:quantities) attribute :schema_version, :string attribute :version, :string attribute :quantities, Quantity, collection: true diff --git a/lib/unitsdb/quantity.rb b/lib/unitsdb/quantity.rb index dc28134..01c0852 100644 --- a/lib/unitsdb/quantity.rb +++ b/lib/unitsdb/quantity.rb @@ -2,8 +2,6 @@ module Unitsdb class Quantity < Lutaml::Model::Serializable - # model Config.model_for(:quantity) - attribute :identifiers, Identifier, collection: true attribute :quantity_type, :string attribute :names, LocalizedString, collection: true diff --git a/lib/unitsdb/quantity_reference.rb b/lib/unitsdb/quantity_reference.rb index 5623dcf..1fb782f 100644 --- a/lib/unitsdb/quantity_reference.rb +++ b/lib/unitsdb/quantity_reference.rb @@ -2,8 +2,6 @@ module Unitsdb class QuantityReference < Identifier - # model Config.model_for(:quantity_reference) - attribute :id, :string attribute :type, :string end diff --git a/lib/unitsdb/root_unit_reference.rb b/lib/unitsdb/root_unit_reference.rb index 96f2a4c..6583ca4 100644 --- a/lib/unitsdb/root_unit_reference.rb +++ b/lib/unitsdb/root_unit_reference.rb @@ -2,8 +2,6 @@ module Unitsdb class RootUnitReference < Lutaml::Model::Serializable - # model Config.model_for(:root_unit) - attribute :power, :integer attribute :unit_reference, UnitReference attribute :prefix_reference, PrefixReference diff --git a/lib/unitsdb/scale.rb b/lib/unitsdb/scale.rb index 5fe2053..5c44436 100644 --- a/lib/unitsdb/scale.rb +++ b/lib/unitsdb/scale.rb @@ -2,8 +2,6 @@ module Unitsdb class Scale < Lutaml::Model::Serializable - # model Config.model_for(:quantity) - attribute :identifiers, Identifier, collection: true attribute :names, LocalizedString, collection: true attribute :description, LocalizedString, collection: true diff --git a/lib/unitsdb/scale_properties.rb b/lib/unitsdb/scale_properties.rb index 1780475..c6d6aab 100644 --- a/lib/unitsdb/scale_properties.rb +++ b/lib/unitsdb/scale_properties.rb @@ -2,7 +2,6 @@ module Unitsdb class ScaleProperties < Lutaml::Model::Serializable - # model Config.model_for(:quantity) attribute :continuous, :boolean attribute :ordered, :boolean attribute :logarithmic, :boolean diff --git a/lib/unitsdb/scales.rb b/lib/unitsdb/scales.rb index b2ae397..ec35488 100644 --- a/lib/unitsdb/scales.rb +++ b/lib/unitsdb/scales.rb @@ -2,7 +2,6 @@ module Unitsdb class Scales < Lutaml::Model::Serializable - # model Config.model_for(:Scale) attribute :schema_version, :string attribute :version, :string attribute :scales, Scale, collection: true diff --git a/lib/unitsdb/si_derived_base.rb b/lib/unitsdb/si_derived_base.rb index 0903bfe..d5605b1 100644 --- a/lib/unitsdb/si_derived_base.rb +++ b/lib/unitsdb/si_derived_base.rb @@ -12,7 +12,6 @@ module Unitsdb class SiDerivedBase < RootUnitReference - # model Config.model_for(:si_derived_base) end Config.register_model(SiDerivedBase, id: :si_derived_base) diff --git a/lib/unitsdb/symbol_presentations.rb b/lib/unitsdb/symbol_presentations.rb index 19e5bed..8b8e803 100644 --- a/lib/unitsdb/symbol_presentations.rb +++ b/lib/unitsdb/symbol_presentations.rb @@ -2,8 +2,6 @@ module Unitsdb class SymbolPresentations < Lutaml::Model::Serializable - # model Config.model_for(:symbol_presentations) - attribute :id, :string attribute :ascii, :string attribute :html, :string diff --git a/lib/unitsdb/unit.rb b/lib/unitsdb/unit.rb index 958515b..a98a4d8 100644 --- a/lib/unitsdb/unit.rb +++ b/lib/unitsdb/unit.rb @@ -1,41 +1,7 @@ # frozen_string_literal: true -# "NISTu10": -# dimension_url: "#NISTd9" -# short: steradian -# root: true -# unit_system: -# type: "SI_derived_special" -# name: "SI" -# names: -# - "steradian" -# unit_symbols: -# - id: "sr" -# ascii: "sr" -# html: "sr" -# mathml: "sr" -# latex: \ensuremath{\mathrm{sr}} -# unicode: "sr" -# root_units: -# enumerated_root_units: -# - unit: "steradian" -# power_denominator: 1 -# power: 1 -# quantity_reference: -# - name: "solid angle" -# url: "#NISTq11" -# si_derived_bases: -# - id: NISTu1 -# prefix: -# power: 1 -# - id: NISTu1 -# prefix: -# power: -1 - module Unitsdb class Unit < Lutaml::Model::Serializable - # model Config.model_for(:unit) - attribute :identifiers, Identifier, collection: true attribute :short, :string attribute :root, :boolean diff --git a/lib/unitsdb/unit_system.rb b/lib/unitsdb/unit_system.rb index d6d9ff7..1adb462 100644 --- a/lib/unitsdb/unit_system.rb +++ b/lib/unitsdb/unit_system.rb @@ -2,8 +2,6 @@ module Unitsdb class UnitSystem < Lutaml::Model::Serializable - # model Config.model_for(:unit_system) - attribute :identifiers, Identifier, collection: true attribute :names, LocalizedString, collection: true attribute :short, :string diff --git a/lib/unitsdb/unit_systems.rb b/lib/unitsdb/unit_systems.rb index 5000acf..f982a1c 100644 --- a/lib/unitsdb/unit_systems.rb +++ b/lib/unitsdb/unit_systems.rb @@ -2,8 +2,6 @@ module Unitsdb class UnitSystems < Lutaml::Model::Serializable - # model Config.model_for(:unit_systems) - attribute :schema_version, :string attribute :version, :string attribute :unit_systems, UnitSystem, collection: true diff --git a/lib/unitsdb/units.rb b/lib/unitsdb/units.rb index 24f969d..60636d0 100644 --- a/lib/unitsdb/units.rb +++ b/lib/unitsdb/units.rb @@ -2,8 +2,6 @@ module Unitsdb class Units < Lutaml::Model::Serializable - # model Config.model_for(:units) - attribute :schema_version, :string attribute :version, :string attribute :units, Unit, collection: true diff --git a/spec/unitsdb/bundled_data_spec.rb b/spec/unitsdb/bundled_data_spec.rb index 136a7d3..dd85a16 100644 --- a/spec/unitsdb/bundled_data_spec.rb +++ b/spec/unitsdb/bundled_data_spec.rb @@ -34,11 +34,11 @@ describe ".database" do around do |example| - described_class.instance_variable_set(:@databases, nil) + described_class.reset_database_cache! Lutaml::Model::GlobalContext.reset! example.run ensure - described_class.instance_variable_set(:@databases, nil) + described_class.reset_database_cache! Lutaml::Model::GlobalContext.reset! end diff --git a/spec/unitsdb/cli_spec.rb b/spec/unitsdb/cli_spec.rb index 8839181..84ba59c 100644 --- a/spec/unitsdb/cli_spec.rb +++ b/spec/unitsdb/cli_spec.rb @@ -1,20 +1,102 @@ # frozen_string_literal: true require "spec_helper" -require_relative "../../lib/unitsdb/cli" -require "stringio" +require "unitsdb/cli" RSpec.describe Unitsdb::Cli do - let(:cli) { described_class.new } + let(:database_path) { "data" } - # No global output capture - each test will capture output explicitly + describe "subcommand dispatch" do + it "registers validate as a subcommand" do + expect(described_class.subcommand_classes["validate"]) + .to eq(Unitsdb::Commands::ValidateCommand) + end + + it "registers _modify as a subcommand" do + expect(described_class.subcommand_classes["_modify"]) + .to eq(Unitsdb::Commands::ModifyCommand) + end + + it "registers ucum as a subcommand" do + expect(described_class.subcommand_classes["ucum"]) + .to eq(Unitsdb::Commands::UcumCommand) + end + + it "registers qudt as a subcommand" do + expect(described_class.subcommand_classes["qudt"]) + .to eq(Unitsdb::Commands::QudtCommand) + end + end - # normalize is now a subcommand under _modify - # The original normalize tests are no longer applicable + describe "search subcommand" do + it "prints matches for a known term" do + output = capture_output do + described_class.start(%W[search meter -d #{database_path}]) + end + + expect(output[:output]).to include("Found") + expect(output[:output]).to include("Unit") + end + + it "prints a no-results message for an unknown term" do + output = capture_output do + described_class.start(%W[search zzz-no-such-term -d #{database_path}]) + end + + expect(output[:output]).to include("No results found") + end + end + + describe "get subcommand" do + it "prints entity details for a known id" do + output = capture_output do + described_class.start(%W[get NISTu1 -d #{database_path}]) + end + + expect(output[:output]).to include("Entity details:") + expect(output[:output]).to include("Type: Unit") + end + + it "prints a not-found message for an unknown id" do + output = capture_output do + described_class.start(%W[get zzz-no-such-id -d #{database_path}]) + end + + expect(output[:output]).to include("No entity found with ID:") + end + + it "supports --format json" do + output = capture_output do + described_class.start(%W[get NISTu1 -d #{database_path} --format json]) + end + + expect(output[:output]).to include('"short":') + end + end describe "validate subcommand" do - it "registers the ValidateCommand as a subcommand" do - expect(described_class.subcommand_classes["validate"]).to eq(Unitsdb::Commands::ValidateCommand) + it "runs `validate references` and prints a summary" do + output = capture_output do + described_class.start(%W[validate references -d #{database_path}]) + end + + expect(output[:output]).to match(/references are valid|invalid references/i) + end + + it "runs `validate identifiers`" do + output = capture_output do + described_class.start(%W[validate identifiers -d #{database_path}]) + end + + expect(output[:output]).to match(/duplicate 'short'|duplicate 'id'|No duplicate/i) + end + end + + describe "--trace" do + it "re-raises the underlying error when --trace is set" do + expect do + described_class.start(%w[get NISTu1 -d /does/not/exist --trace]) + end.to raise_error(Unitsdb::Errors::DatabaseError) end end end diff --git a/spec/unitsdb/commands/validate/qudt_references_spec.rb b/spec/unitsdb/commands/validate/qudt_references_spec.rb index c95eef4..7cdfaa0 100644 --- a/spec/unitsdb/commands/validate/qudt_references_spec.rb +++ b/spec/unitsdb/commands/validate/qudt_references_spec.rb @@ -8,53 +8,45 @@ let(:options) { { database: database_path } } describe "#run" do - it "can be instantiated and run without errors" do - command = described_class.new(options) - - # The command should be able to be instantiated - expect(command).to be_a(described_class) - - # Should run without raising an exception - expect { command.run }.not_to raise_error - end - - it "loads the database correctly" do - command = described_class.new(options) - - # Should not raise an error when loading the database - expect { command.run }.not_to raise_error + it "runs against the bundled database without raising" do + expect { capture_output { described_class.new(options).run } }.not_to raise_error end - it "handles database errors gracefully" do - invalid_options = { database: "/nonexistent/path" } - command = described_class.new(invalid_options) - - # Should raise ValidationError for invalid database path + it "raises ValidationError when the database path is missing" do expect do - command.run + described_class.new(database: "/nonexistent/path").run end.to raise_error(Unitsdb::Errors::ValidationError) do |error| expect(error.message).to include("Failed to validate QUDT references") end end - it "validates QUDT references across all entity types" do - command = described_class.new(options) + it "flags two units sharing the same QUDT URI" do + shared_ref = Unitsdb::ExternalReference.new( + uri: "http://qudt.org/vocab/unit/M", + type: "normative", + authority: "qudt", + ) + unit_a = Unitsdb::Unit.new( + identifiers: [Unitsdb::Identifier.new(id: "NISTu1", type: "nist")], + short: "meter_a", + names: [Unitsdb::LocalizedString.new(value: "meter a", lang: "en")], + references: [shared_ref], + ) + unit_b = Unitsdb::Unit.new( + identifiers: [Unitsdb::Identifier.new(id: "NISTu2", type: "nist")], + short: "meter_b", + names: [Unitsdb::LocalizedString.new(value: "meter b", lang: "en")], + references: [shared_ref], + ) + db = Unitsdb::Database.new + db.units = [unit_a, unit_b] - # Mock the database to have entities with QUDT references - db = double("database") + command = described_class.new(options) allow(command).to receive(:load_database).and_return(db) - # Mock entities with no references (should pass validation) - units = [double("unit", references: nil)] - quantities = [double("quantity", references: [])] - dimensions = [double("dimension", references: nil)] - unit_systems = [double("unit_system", references: [])] - - allow(db).to receive_messages(units: units, quantities: quantities, - dimensions: dimensions, unit_systems: unit_systems) - - # Should run without errors - expect { command.run }.not_to raise_error + output = capture_output { command.run } + expect(output[:output]).to include("Found duplicate QUDT references:") + expect(output[:output]).to include("http://qudt.org/vocab/unit/M") end end end diff --git a/spec/unitsdb/commands/validate/references_spec.rb b/spec/unitsdb/commands/validate/references_spec.rb index 7cb7d25..87d1f6f 100644 --- a/spec/unitsdb/commands/validate/references_spec.rb +++ b/spec/unitsdb/commands/validate/references_spec.rb @@ -2,231 +2,111 @@ require "spec_helper" require "unitsdb/commands/validate/references" -require "stringio" require "fileutils" require "yaml" RSpec.describe Unitsdb::Commands::Validate::References do - let(:command) { described_class.new(options) } - let(:options) { { database: fixtures_dir } } let(:fixtures_dir) { File.join("spec", "fixtures", "test_references") } + let(:options) { { database: fixtures_dir } } + let(:command) { described_class.new(options) } - before(:all) do - # Create test fixtures directory - FileUtils.mkdir_p(File.join("spec", "fixtures", "test_references")) - end - - after(:all) do - # Clean up test fixtures - FileUtils.rm_rf(File.join("spec", "fixtures", "test_references")) + around do |example| + FileUtils.mkdir_p(fixtures_dir) + example.run + ensure + FileUtils.rm_rf(fixtures_dir) end - # No global output capture - each test will capture output explicitly - - describe "#check" do - context "with valid references" do + describe "#run" do + context "with a database whose references are all valid" do before do - # Create test fixture files with valid references - create_test_fixtures(true) - - # Mock database loading - allow(command).to receive(:load_database).and_return(test_database) + write_fixture( + dimensions: [ + { + "identifiers" => [{ "id" => "NISTd1", "type" => "nist" }], + "short" => "L", + "names" => [{ "value" => "length", "lang" => "en" }], + }, + ], + units: [ + { + "identifiers" => [{ "id" => "NISTu1", "type" => "nist" }], + "short" => "meter", + "names" => [{ "value" => "meter", "lang" => "en" }], + "dimension_reference" => { "id" => "NISTd1", "type" => "nist" }, + "unit_system_reference" => [{ "id" => "si-base", + "type" => "unitsml" }], + }, + ], + unit_systems: [ + { + "identifiers" => [{ "id" => "si-base", "type" => "unitsml" }], + "short" => "SI", + "names" => [{ "value" => "SI base", "lang" => "en" }], + }, + ], + ) end it "reports that all references are valid" do - output = capture_output do - command.run - end + output = capture_output { command.run } expect(output[:output]).to include("All references are valid!") end - - context "with --print_valid option" do - let(:options) { { database: fixtures_dir, print_valid: true } } - - it "prints valid references when --print_valid is specified" do - output = capture_output do - command.run - end - - expect(output[:output]).to include("Valid reference:") - expect(output[:output]).to include("All references are valid!") - end - end - - context "with debug_registry option" do - let(:options) { { database: fixtures_dir, debug_registry: true } } - - it "shows registry contents when --debug_registry is specified" do - output = capture_output do - command.run - end - - expect(output[:output]).to include("Registry contents:") - expect(output[:output]).to include("All references are valid!") - end - end end - context "with invalid references" do + context "with a database containing a broken unit_system_reference" do before do - # Create test fixture files with invalid references - create_test_fixtures(false) - - # Mock database loading with actual validation failures - db = test_database - - # Mock the load_database method to avoid file access errors - - # Create a known registry - mock_registry = { - "units" => { "NISTu1" => "index:0", "nist:NISTu1" => "index:0" }, - "dimensions" => { "NISTd1" => "index:0", "nist:NISTd1" => "index:0" }, - "unit_systems" => { "si-base" => "index:0", - "unitsml:si-base" => "index:0" }, - } - - # Mock the build_id_registry method - - # Mock the check_references method - invalid_refs = { - "units" => { - "units:index:0:unit_system_reference[0]" => { - id: "invalid-system", - type: "unitsml", - ref_type: "unit_systems", + write_fixture( + units: [ + { + "identifiers" => [{ "id" => "NISTu1", "type" => "nist" }], + "short" => "meter", + "names" => [{ "value" => "meter", "lang" => "en" }], + "unit_system_reference" => [{ "id" => "invalid-system", + "type" => "unitsml" }], }, - }, - } - allow(command).to receive_messages(load_database: db, - build_id_registry: mock_registry, check_references: invalid_refs) - - # Allow the Unitsdb::Utils.find_similar_ids method to work normally - similar_ids = ["si-base"] - allow(Unitsdb::Utils).to receive(:find_similar_ids).and_return(similar_ids) + ], + unit_systems: [ + { + "identifiers" => [{ "id" => "si-base", "type" => "unitsml" }], + "short" => "SI", + "names" => [{ "value" => "SI base", "lang" => "en" }], + }, + ], + ) end - it "reports invalid references" do - output = capture_output do - command.run - end + it "reports the invalid reference path" do + output = capture_output { command.run } expect(output[:output]).to include("Found invalid references:") expect(output[:output]).to include("units:index:0:unit_system_reference[0]") end + end - it "suggests similar IDs for invalid references" do - output = capture_output do - command.run - end - - expect(output[:output]).to include("Did you mean one of these?") - end + it "raises a ValidationError when the database cannot be loaded" do + expect do + described_class.new(database: "/does/not/exist").run + end.to raise_error(Unitsdb::Errors::ValidationError) end end private - def create_test_fixtures(valid_references) - # Create dimensions.yaml - dimensions = [ - { - "identifiers" => [ - { "id" => "NISTd1", "type" => "nist" }, - ], - "dimension_name" => ["length"], - "short" => "L", - }, - ] - - # Create units.yaml - units = [ - { - "identifiers" => [ - { "id" => "NISTu1", "type" => "nist" }, - ], - "names" => ["meter"], - "short" => "meter", - "dimension_reference" => { "id" => "NISTd1", "type" => "nist" }, - "unit_system_reference" => if valid_references - [{ "id" => "si-base", - "type" => "unitsml" }] - else - [{ "id" => "invalid-system", - "type" => "unitsml" }] - end, - }, - ] - - # Create unit_systems.yaml - unit_systems = [ - { - "identifiers" => [ - { "id" => "si-base", "type" => "unitsml" }, - ], - "unit_system_name" => ["SI base"], - "short" => "SI", - }, - ] - - # Write fixture files - File.write(File.join(fixtures_dir, "dimensions.yaml"), dimensions.to_yaml) - File.write(File.join(fixtures_dir, "units.yaml"), units.to_yaml) - File.write(File.join(fixtures_dir, "unit_systems.yaml"), - unit_systems.to_yaml) - end - - def test_database - # Create an instance of Unitsdb::Database - # This could be a real database loaded from the fixtures - # or a mock with the necessary structure - dimension_identifier = double("Identifier", id: "NISTd1", type: "nist") - allow(dimension_identifier).to receive(:respond_to?).with(any_args).and_return(true) - - dimension = double("Dimension", - identifiers: [dimension_identifier], - short: "L") - allow(dimension).to receive(:respond_to?).with(any_args).and_return(true) - allow(dimension).to receive(:dimension_reference).and_return(nil) - - dimensions = [dimension] - - unit_identifier = double("Identifier", id: "NISTu1", type: "nist") - allow(unit_identifier).to receive(:respond_to?).with(any_args).and_return(true) - - dimension_ref = double("DimensionRef", id: "NISTd1", type: "nist") - allow(dimension_ref).to receive(:respond_to?).with(any_args).and_return(true) - - unit_system_ref = double("UnitSystemRef", id: "si-base", type: "unitsml") - allow(unit_system_ref).to receive(:respond_to?).with(any_args).and_return(true) - - unit = double("Unit", - identifiers: [unit_identifier], - dimension_reference: dimension_ref, - unit_system_reference: [unit_system_ref]) - allow(unit).to receive(:respond_to?).with(any_args).and_return(true) - allow(unit).to receive_messages(root_units: nil, quantity_references: nil) - - units = [unit] - - unit_system_identifier = double("Identifier", id: "si-base", - type: "unitsml") - allow(unit_system_identifier).to receive(:respond_to?).with(any_args).and_return(true) - - unit_system = double("UnitSystem", - identifiers: [unit_system_identifier], - short: "SI") - allow(unit_system).to receive(:respond_to?).with(any_args).and_return(true) - - unit_systems = [unit_system] - - quantities = [] - prefixes = [] - - db = double("Database") - allow(db).to receive_messages(dimensions: dimensions, units: units, - unit_systems: unit_systems, quantities: quantities, prefixes: prefixes) - - db + def write_fixture(dimensions: [], units: [], unit_systems: [], + prefixes: [], quantities: []) + { + "dimensions" => dimensions, + "units" => units, + "unit_systems" => unit_systems, + "prefixes" => prefixes, + "quantities" => quantities, + }.each do |name, payload| + File.write( + File.join(fixtures_dir, "#{name}.yaml"), + { "schema_version" => "2.0.0", name => payload }.to_yaml, + ) + end end end diff --git a/spec/unitsdb/commands/validate/ucum_references_spec.rb b/spec/unitsdb/commands/validate/ucum_references_spec.rb index e48df65..0988b92 100644 --- a/spec/unitsdb/commands/validate/ucum_references_spec.rb +++ b/spec/unitsdb/commands/validate/ucum_references_spec.rb @@ -8,50 +8,45 @@ let(:options) { { database: database_path } } describe "#run" do - it "can be instantiated and run without errors" do - command = described_class.new(options) - - # The command should be able to be instantiated - expect(command).to be_a(described_class) - - # Should run without raising an exception - expect { command.run }.not_to raise_error - end - - it "loads the database correctly" do - command = described_class.new(options) - - # Should not raise an error when loading the database - expect { command.run }.not_to raise_error + it "runs against the bundled database without raising" do + expect { capture_output { described_class.new(options).run } }.not_to raise_error end - it "handles database errors gracefully" do - invalid_options = { database: "/nonexistent/path" } - command = described_class.new(invalid_options) - - # Should raise ValidationError for invalid database path + it "raises ValidationError when the database path is missing" do expect do - command.run + described_class.new(database: "/nonexistent/path").run end.to raise_error(Unitsdb::Errors::ValidationError) do |error| expect(error.message).to include("Failed to validate UCUM references") end end - it "validates UCUM references across all entity types" do - command = described_class.new(options) + it "flags two prefixes sharing the same UCUM code" do + shared_ref = Unitsdb::ExternalReference.new( + uri: "http://unitsofmeasure.org/ucum/k", + type: "normative", + authority: "ucum", + ) + prefix_a = Unitsdb::Prefix.new( + identifiers: [Unitsdb::Identifier.new(id: "NISTp10_3a", type: "nist")], + short: "kilo_a", + names: [Unitsdb::LocalizedString.new(value: "kilo a", lang: "en")], + references: [shared_ref], + ) + prefix_b = Unitsdb::Prefix.new( + identifiers: [Unitsdb::Identifier.new(id: "NISTp10_3b", type: "nist")], + short: "kilo_b", + names: [Unitsdb::LocalizedString.new(value: "kilo b", lang: "en")], + references: [shared_ref], + ) + db = Unitsdb::Database.new + db.prefixes = [prefix_a, prefix_b] - # Mock the database to have entities with UCUM references - db = double("database") + command = described_class.new(options) allow(command).to receive(:load_database).and_return(db) - # Mock entities with no external references (should pass validation) - units = [double("unit", external_references: nil)] - prefixes = [double("prefix", external_references: [])] - - allow(db).to receive_messages(units: units, prefixes: prefixes) - - # Should run without errors - expect { command.run }.not_to raise_error + output = capture_output { command.run } + expect(output[:output]).to include("Found duplicate UCUM references:") + expect(output[:output]).to include("http://unitsofmeasure.org/ucum/k") end end end diff --git a/spec/unitsdb/config_spec.rb b/spec/unitsdb/config_spec.rb index f0da24b..737e44c 100644 --- a/spec/unitsdb/config_spec.rb +++ b/spec/unitsdb/config_spec.rb @@ -6,26 +6,16 @@ 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 + described_class.with_isolated_config do + example.run + end 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) + Unitsdb.reset_database_cache! end it "builds a custom context without implicitly using it as a register" do @@ -47,7 +37,7 @@ expect(context).not_to be_nil expect(context.substitutions.length).to eq(1) - expect(described_class.register(:custom_unitsdb)).to be_nil + expect(described_class.register_id_for(: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) @@ -76,7 +66,7 @@ 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(described_class.register_id_for(:custom_unitsdb_with_register)).not_to be_nil expect(db.units.first).to be_a(CustomContextUnitWithRegister) end @@ -84,7 +74,7 @@ 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.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) @@ -101,9 +91,9 @@ end describe ".context" do - it "rebuilds an existing context when forced" do + it "rebuilds an existing context when re-populated" do initial_context = described_class.context - rebuilt_context = described_class.context(force_populate: true) + rebuilt_context = described_class.populate_context(id: described_class.context_id) expect(rebuilt_context).not_to equal(initial_context) expect(described_class.find_context(described_class.context_id)).to equal(rebuilt_context) diff --git a/spec/unitsdb/database_search_spec.rb b/spec/unitsdb/database_search_spec.rb index a8ab998..174df19 100644 --- a/spec/unitsdb/database_search_spec.rb +++ b/spec/unitsdb/database_search_spec.rb @@ -6,42 +6,175 @@ let(:fixtures_dir) { "data" } let(:database) { described_class.from_db(fixtures_dir) } + around do |example| + Unitsdb.reset_database_cache! + Lutaml::Model::GlobalContext.reset! + example.run + ensure + Unitsdb.reset_database_cache! + Lutaml::Model::GlobalContext.reset! + end + describe "search and lookup functionality" do it "finds entities using search by text with type filters" do - # Generic text search results = database.search(text: "meter") expect(results).not_to be_empty expect(results.any?(Unitsdb::Unit)).to be(true) - # Search with type filter results = database.search(text: "kilo", type: "prefixes") expect(results.all?(Unitsdb::Prefix)).to be(true) - # Handling non-existent terms and nil values expect(database.search(text: "nonexistentterm123456")).to be_empty expect(database.search(text: nil)).to be_empty end it "finds entities by id and type" do - # Find by specific type - entity = database.find_by_type(id: "NISTu1", type: "units") - expect(entity).to be_a(Unitsdb::Unit) + expect(database.find_by_type(id: "NISTu1", type: "units")).to be_a(Unitsdb::Unit) + expect(database.find_by_type(id: "NonExistentID", type: "units")).to be_nil - # Returns nil for non-existent entities - expect(database.find_by_type(id: "NonExistentID", - type: "units")).to be_nil + expect(database.get_by_id(id: "NISTu1")).to be_a(Unitsdb::Unit) + expect(database.get_by_id(id: "NISTu1", type: "nist")).to be_a(Unitsdb::Unit) + expect(database.get_by_id(id: "NonExistentID")).to be_nil + expect(database.get_by_id(id: "NISTu1", type: "wrong_type")).to be_nil + end - # Find by id across all types - entity = database.get_by_id(id: "NISTu1") - expect(entity).to be_a(Unitsdb::Unit) + it "rejects an unknown collection name" do + expect { database.find_by_type(id: "x", type: "bogus") }.to raise_error(ArgumentError) + expect { database.search(text: "x", type: "bogus") }.to raise_error(ArgumentError) + end + end - # Find with identifier type filter - entity = database.get_by_id(id: "NISTu1", type: "nist") - expect(entity).to be_a(Unitsdb::Unit) + describe "#find_by_symbol" do + it "matches Units by ASCII symbol case-insensitively" do + results = database.find_by_symbol("m") + expect(results).not_to be_empty + expect(results.any?(Unitsdb::Unit)).to be(true) + end - # Returns nil for non-existent IDs or wrong types - expect(database.get_by_id(id: "NonExistentID")).to be_nil - expect(database.get_by_id(id: "NISTu1", type: "wrong_type")).to be_nil + it "matches Prefixes when no entity_type is given" do + results = database.find_by_symbol("k") + expect(results.any?(Unitsdb::Prefix)).to be(true) + end + + it "narrows to one collection when entity_type is provided" do + only_units = database.find_by_symbol("m", "units") + expect(only_units).not_to be_empty + expect(only_units.all?(Unitsdb::Unit)).to be(true) + + only_prefixes = database.find_by_symbol("k", "prefixes") + expect(only_prefixes.all?(Unitsdb::Prefix)).to be(true) + end + + it "returns an empty array for an unknown symbol" do + expect(database.find_by_symbol("zz-not-a-symbol")).to eq([]) + end + + it "returns an empty array when called with nil" do + expect(database.find_by_symbol(nil)).to eq([]) + end + + it "raises ArgumentError when the entity_type is not a symbol-carrying collection" do + expect { database.find_by_symbol("m", "quantities") }.to raise_error(ArgumentError) + end + end + + describe "#match_entities" do + it "returns an empty hash when value is nil" do + expect(database.match_entities(value: nil)).to eq({}) + end + + it "matches Units by short" do + result = database.match_entities(value: "meter") + expect(result[:exact]).not_to be_nil + expect(result[:exact].any? { |m| m[:entity].is_a?(Unitsdb::Unit) }).to be(true) + end + + it "matches by symbol when match_type is 'symbol'" do + result = database.match_entities(value: "m", match_type: "symbol") + expect(result[:symbol_match]).not_to be_nil + expect(result[:symbol_match].first[:match_desc]).to eq("symbol_match") + end + + it "limits scope when entity_type is provided" do + result = database.match_entities(value: "meter", entity_type: "units") + expect(result[:exact].all? { |m| m[:entity].is_a?(Unitsdb::Unit) }).to be(true) + end + + it "returns an empty hash for an unknown value" do + expect(database.match_entities(value: "zz-not-an-entity")).to eq({}) + end + + it "drops empty categories from the result" do + result = database.match_entities(value: "meter") + expect(result.key?(:symbol_match)).to be(false) unless result[:symbol_match]&.any? + end + end + + describe "#validate_uniqueness" do + it "returns a hash with :short and :id keys" do + result = database.validate_uniqueness + expect(result).to include(:short, :id) + end + + it "flags a synthetic duplicate identifier" do + dup_id = Unitsdb::Identifier.new(id: "NISTu1", type: "nist") + unit = Unitsdb::Unit.new(identifiers: [dup_id]) + database.units << unit + + result = database.validate_uniqueness + expect(result[:id]["units"]).to include("NISTu1") + end + + it "flags a synthetic duplicate short name" do + dup_short = database.units.first.short + unit = Unitsdb::Unit.new( + identifiers: [Unitsdb::Identifier.new(id: "NISTu-synthetic", type: "nist")], + short: dup_short, + ) + database.units << unit + + result = database.validate_uniqueness + expect(result[:short]["units"]).to include(dup_short) + end + end + + describe "#validate_references" do + it "returns a hash (empty when bundled DB has no broken refs)" do + result = database.validate_references + expect(result).to be_a(Hash) + end + + it "flags a Unit whose unit_system_reference points nowhere" do + bogus_ref = Unitsdb::UnitSystemReference.new(id: "bogus-system", type: "unitsml") + unit = Unitsdb::Unit.new( + identifiers: [Unitsdb::Identifier.new(id: "NISTu-test", type: "nist")], + short: "test_unit", + names: [Unitsdb::LocalizedString.new(value: "test", lang: "en")], + unit_system_reference: [bogus_ref], + ) + database.units << unit + + result = database.validate_references + expect(result["units"]).to include( + "units:index:#{database.units.size - 1}:unit_system_reference[0]", + ) + end + + it "flags a Unit whose root_units.unit_reference points nowhere" do + bogus_root = Unitsdb::RootUnitReference.new( + unit_reference: Unitsdb::UnitReference.new(id: "NISTu-missing", type: "nist"), + ) + unit = Unitsdb::Unit.new( + identifiers: [Unitsdb::Identifier.new(id: "NISTu-test-2", type: "nist")], + short: "test_unit_2", + names: [Unitsdb::LocalizedString.new(value: "test 2", lang: "en")], + root_units: [bogus_root], + ) + database.units << unit + + result = database.validate_references + path = "units:index:#{database.units.size - 1}:root_units.0.unit_reference" + expect(result["units"]).to include(path) end end end diff --git a/spec/unitsdb/database_spec.rb b/spec/unitsdb/database_spec.rb index 07d74b6..59ff318 100644 --- a/spec/unitsdb/database_spec.rb +++ b/spec/unitsdb/database_spec.rb @@ -10,12 +10,12 @@ end around do |example| + Unitsdb.reset_database_cache! Lutaml::Model::GlobalContext.reset! - Unitsdb.instance_variable_set(:@databases, nil) example.run ensure + Unitsdb.reset_database_cache! Lutaml::Model::GlobalContext.reset! - Unitsdb.instance_variable_set(:@databases, nil) end it "parses the full unitsdb database" do @@ -62,7 +62,7 @@ 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), + registry: Unitsdb::Config.build_registry, fallback_to: [:default], substitutions: [], ) diff --git a/spec/unitsdb/unitsdb_bundled_entities_spec.rb b/spec/unitsdb/unitsdb_bundled_entities_spec.rb new file mode 100644 index 0000000..ab5ac82 --- /dev/null +++ b/spec/unitsdb/unitsdb_bundled_entities_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Typed-attribute checks for known bundled entities. The per-entity +# *_spec.rb files already cover YAML round-trip for every entity in +# the data dir; these specs complement them by asserting typed +# attribute access on representative entities so regressions in +# Identifier / LocalizedString / SymbolPresentations / +# ExternalReference wiring surface as test failures, not just +# round-trip mismatches. +RSpec.describe Unitsdb do + let(:database) do + described_class.reset_database_cache! + Lutaml::Model::GlobalContext.reset! + described_class.database.tap do + described_class.reset_database_cache! + Lutaml::Model::GlobalContext.reset! + end + end + + describe "the meter Unit (NISTu1)" do + let(:meter) { database.get_by_id(id: "NISTu1") } + + it "is a Unit with identifiers, names, short, and symbols" do + expect(meter).to be_a(Unitsdb::Unit) + expect(meter.short).to eq("meter") + expect(meter.identifiers.map(&:id)).to include("NISTu1") + + name = meter.names.first + expect(name).to be_a(Unitsdb::LocalizedString) + expect(name.value).to match(/met(er|re)/) + + symbol = meter.symbols.first + expect(symbol).to be_a(Unitsdb::SymbolPresentations) + expect(symbol.ascii).to eq("m") + end + + it "round-trips through YAML with all attributes intact" do + parsed = Unitsdb::Unit.from_yaml(meter.to_yaml) + expect(parsed.short).to eq(meter.short) + expect(parsed.identifiers.first.id).to eq("NISTu1") + expect(parsed.symbols.first.ascii).to eq("m") + end + end + + describe "the kilo Prefix (NISTp10_3)" do + let(:kilo) { database.get_by_id(id: "NISTp10_3") } + + it "is a Prefix with short, base, power, and symbols" do + expect(kilo).to be_a(Unitsdb::Prefix) + expect(kilo.short).to eq("kilo") + expect(kilo.base).to eq(10) + expect(kilo.power).to eq(3) + + symbol = kilo.symbols.first + expect(symbol).to be_a(Unitsdb::SymbolPresentations) + expect(symbol.ascii).to eq("k") + end + + it "round-trips through YAML" do + parsed = Unitsdb::Prefix.from_yaml(kilo.to_yaml) + expect(parsed.short).to eq("kilo") + expect(parsed.power).to eq(3) + end + end + + describe "the length Dimension (NISTd1)" do + let(:length) { database.get_by_id(id: "NISTd1") } + + it "is a Dimension with a length DimensionDetails entry" do + expect(length).to be_a(Unitsdb::Dimension) + expect(length.short).to eq("length") + expect(length.length).to be_a(Unitsdb::DimensionDetails) + expect(length.length.power).to eq(1) + end + end + + describe "the length Quantity (NISTq1)" do + let(:quantity) { database.get_by_id(id: "NISTq1") } + + it "is a Quantity with short and dimension_reference" do + expect(quantity).to be_a(Unitsdb::Quantity) + expect(quantity.short).to eq("length") + expect(quantity.dimension_reference).to be_a(Unitsdb::DimensionReference) + end + end + + describe "the SI_base UnitSystem" do + let(:si) { database.get_by_id(id: "SI_base") } + + it "is a UnitSystem with names" do + expect(si).to be_a(Unitsdb::UnitSystem) + expect(si.identifiers.map(&:id)).to include("SI_base") + expect(si.names.first).to be_a(Unitsdb::LocalizedString) + end + end + + describe "Identifier and LocalizedString leaves" do + it "Identifier round-trips its two attrs" do + ident = Unitsdb::Identifier.new(id: "X", type: "nist") + parsed = Unitsdb::Identifier.from_yaml(ident.to_yaml) + expect(parsed.id).to eq("X") + expect(parsed.type).to eq("nist") + end + + it "LocalizedString round-trips value and lang" do + str = Unitsdb::LocalizedString.new(value: "hello", lang: "en") + parsed = Unitsdb::LocalizedString.from_yaml(str.to_yaml) + expect(parsed.value).to eq("hello") + expect(parsed.lang).to eq("en") + end + + it "SymbolPresentations round-trips ASCII and MathML" do + sym = Unitsdb::SymbolPresentations.new( + id: "sym_m", ascii: "m", mathml: "m", + ) + parsed = Unitsdb::SymbolPresentations.from_yaml(sym.to_yaml) + expect(parsed.ascii).to eq("m") + expect(parsed.mathml).to eq("m") + end + + it "ExternalReference round-trips uri, type, authority" do + ref = Unitsdb::ExternalReference.new( + uri: "http://example.org/x", type: "normative", authority: "custom", + ) + parsed = Unitsdb::ExternalReference.from_yaml(ref.to_yaml) + expect(parsed.uri).to eq("http://example.org/x") + expect(parsed.authority).to eq("custom") + end + end +end