unitsdb 2.2.2 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/opal.yml +36 -0
  3. data/.gitignore +3 -0
  4. data/Gemfile +2 -1
  5. data/Rakefile +3 -1
  6. data/lib/unitsdb/cli.rb +5 -41
  7. data/lib/unitsdb/commands/_modify.rb +1 -34
  8. data/lib/unitsdb/commands/check_si/si_formatter.rb +6 -6
  9. data/lib/unitsdb/commands/check_si/si_matcher.rb +202 -292
  10. data/lib/unitsdb/commands/check_si/si_updater.rb +16 -36
  11. data/lib/unitsdb/commands/entity_presenter.rb +98 -0
  12. data/lib/unitsdb/commands/get.rb +16 -113
  13. data/lib/unitsdb/commands/qudt/formatter.rb +16 -27
  14. data/lib/unitsdb/commands/qudt/matcher.rb +18 -28
  15. data/lib/unitsdb/commands/qudt/updater.rb +8 -11
  16. data/lib/unitsdb/commands/qudt.rb +1 -34
  17. data/lib/unitsdb/commands/search.rb +33 -188
  18. data/lib/unitsdb/commands/thor.rb +41 -0
  19. data/lib/unitsdb/commands/ucum/formatter.rb +9 -18
  20. data/lib/unitsdb/commands/ucum/matcher.rb +4 -4
  21. data/lib/unitsdb/commands/ucum/updater.rb +3 -5
  22. data/lib/unitsdb/commands/ucum.rb +1 -34
  23. data/lib/unitsdb/commands/validate/qudt_references.rb +29 -70
  24. data/lib/unitsdb/commands/validate/references.rb +5 -303
  25. data/lib/unitsdb/commands/validate/si_references.rb +30 -66
  26. data/lib/unitsdb/commands/validate/ucum_references.rb +30 -64
  27. data/lib/unitsdb/commands/validate.rb +1 -36
  28. data/lib/unitsdb/commands.rb +2 -0
  29. data/lib/unitsdb/config.rb +83 -29
  30. data/lib/unitsdb/database/loader.rb +135 -0
  31. data/lib/unitsdb/database/reference_validator.rb +227 -0
  32. data/lib/unitsdb/database/uniqueness_validator.rb +80 -0
  33. data/lib/unitsdb/database.rb +124 -584
  34. data/lib/unitsdb/dimension.rb +0 -27
  35. data/lib/unitsdb/dimensions.rb +0 -2
  36. data/lib/unitsdb/opal.rb +43 -0
  37. data/lib/unitsdb/prefix.rb +0 -13
  38. data/lib/unitsdb/prefixes.rb +0 -2
  39. data/lib/unitsdb/quantities.rb +0 -1
  40. data/lib/unitsdb/quantity.rb +0 -2
  41. data/lib/unitsdb/quantity_reference.rb +0 -2
  42. data/lib/unitsdb/root_unit_reference.rb +0 -2
  43. data/lib/unitsdb/scale.rb +0 -2
  44. data/lib/unitsdb/scale_properties.rb +0 -1
  45. data/lib/unitsdb/scales.rb +0 -1
  46. data/lib/unitsdb/si_derived_base.rb +0 -1
  47. data/lib/unitsdb/symbol_presentations.rb +0 -2
  48. data/lib/unitsdb/unit.rb +0 -34
  49. data/lib/unitsdb/unit_system.rb +0 -2
  50. data/lib/unitsdb/unit_systems.rb +0 -2
  51. data/lib/unitsdb/units.rb +0 -2
  52. data/lib/unitsdb/version.rb +1 -1
  53. data/lib/unitsdb.rb +142 -35
  54. data/unitsdb.gemspec +1 -0
  55. metadata +23 -2
@@ -2,16 +2,15 @@
2
2
 
3
3
  module Unitsdb
4
4
  class Database < Lutaml::Model::Serializable
5
- # model Config.model_for(:units)
5
+ autoload :ReferenceValidator, "unitsdb/database/reference_validator"
6
+ autoload :UniquenessValidator, "unitsdb/database/uniqueness_validator"
7
+ autoload :Loader, "unitsdb/database/loader"
6
8
 
7
- DATABASE_FILES = {
8
- "prefixes" => "prefixes.yaml",
9
- "dimensions" => "dimensions.yaml",
10
- "units" => "units.yaml",
11
- "quantities" => "quantities.yaml",
12
- "unit_systems" => "unit_systems.yaml",
13
- }.freeze
14
- SUPPORTED_SCHEMA_VERSION = "2.0.0"
9
+ COLLECTIONS = Loader::DATABASE_FILES.keys.map(&:to_sym).freeze
10
+
11
+ # Collections whose entities carry a `symbols` attribute.
12
+ # Used by symbol-based search/match to narrow iteration.
13
+ SYMBOL_COLLECTIONS = %i[units prefixes].freeze
15
14
 
16
15
  attribute :schema_version, :string
17
16
  attribute :version, :string
@@ -21,16 +20,47 @@ module Unitsdb
21
20
  attribute :dimensions, Dimension, collection: true
22
21
  attribute :unit_systems, UnitSystem, collection: true
23
22
 
23
+ # Resolve a collection name (String or Symbol) to its typed Array.
24
+ # Validates against COLLECTIONS so caller-supplied names can never
25
+ # dispatch to arbitrary methods (e.g. `schema_version`, `to_yaml`).
26
+ def collection(name)
27
+ sym = name.to_sym
28
+ unless COLLECTIONS.include?(sym)
29
+ raise ArgumentError, "unknown collection: #{name.inspect}"
30
+ end
31
+
32
+ public_send(sym)
33
+ end
34
+
35
+ # Build an empty Database with `entities` partitioned into their
36
+ # typed collections. Used by CLI commands that need to serialize
37
+ # a subset of search results.
38
+ def self.empty_for_results(entities)
39
+ Database.new.tap do |db|
40
+ COLLECTIONS.each do |name|
41
+ klass = collection_element_class(name)
42
+ db.public_send("#{name}=", entities.grep(klass))
43
+ end
44
+ end
45
+ end
46
+
47
+ class << self
48
+ private
49
+
50
+ # Map a collection symbol to the entity class it holds.
51
+ # Derived from the Lutaml attribute declaration.
52
+ def collection_element_class(name)
53
+ attributes.fetch(name.to_s).type
54
+ end
55
+ end
56
+
24
57
  # Find an entity by its specific identifier and type
25
58
  # @param id [String] the identifier value to search for
26
59
  # @param type [String, Symbol] the entity type (units, prefixes, quantities, etc.)
27
60
  # @return [Object, nil] the first entity with matching identifier or nil if not found
28
61
  def find_by_type(id:, type:)
29
- collection = send(type.to_s)
30
- collection.find do |entity|
31
- entity.identifiers&.any? do |identifier|
32
- identifier.id == id
33
- end
62
+ collection(type).find do |entity|
63
+ entity.identifiers.any? { |identifier| identifier.id == id }
34
64
  end
35
65
  end
36
66
 
@@ -39,82 +69,36 @@ module Unitsdb
39
69
  # @param type [String, nil] optional identifier type to match
40
70
  # @return [Object, nil] the first entity with matching identifier or nil if not found
41
71
  def get_by_id(id:, type: nil)
42
- %w[units prefixes quantities dimensions
43
- unit_systems].each do |collection_name|
44
- next unless respond_to?(collection_name)
45
-
46
- collection = send(collection_name)
47
- entity = collection.find do |e|
48
- e.identifiers&.any? do |identifier|
72
+ COLLECTIONS.each do |name|
73
+ entity = collection(name).find do |e|
74
+ e.identifiers.any? do |identifier|
49
75
  identifier.id == id && (type.nil? || identifier.type == type)
50
76
  end
51
77
  end
52
-
53
78
  return entity if entity
54
79
  end
55
80
 
56
81
  nil
57
82
  end
58
83
 
59
- # Search for entities containing the given text in identifiers, names, or short description
84
+ # Search for entities containing the given text in identifiers,
85
+ # names, or short description.
60
86
  # @param params [Hash] search parameters
61
87
  # @option params [String] :text The text to search for
62
88
  # @option params [String, Symbol, nil] :type Optional entity type to limit search scope
63
89
  # @return [Array] all entities matching the search criteria
64
90
  def search(params = {})
65
91
  text = params[:text]
66
- type = params[:type]
67
-
68
92
  return [] unless text
69
93
 
70
- results = []
71
-
72
- # Define which collections to search based on type parameter
73
- collections = if type
74
- [type.to_s]
75
- else
76
- %w[units prefixes quantities
77
- dimensions unit_systems]
78
- end
79
-
80
- collections.each do |collection_name|
81
- next unless respond_to?(collection_name)
94
+ needle = text.downcase
95
+ scope = scope_for(params[:type], COLLECTIONS)
82
96
 
83
- collection = send(collection_name)
84
- collection.each do |entity|
85
- # Search in identifiers
86
- if entity.identifiers&.any? do |identifier|
87
- identifier.id.to_s.downcase.include?(text.downcase)
88
- end
89
- results << entity
90
- next
91
- end
92
-
93
- # Search in names (if the entity has names)
94
- if entity.respond_to?(:names) && entity.names && entity.names.any? do |name|
95
- name.value.to_s.downcase.include?(text.downcase)
96
- end
97
- results << entity
98
- next
99
- end
100
-
101
- # Search in short description
102
- if entity.respond_to?(:short) && entity.short &&
103
- entity.short.to_s.downcase.include?(text.downcase)
104
- results << entity
105
- next
106
- end
107
-
108
- # Special case for prefix name (prefixes don't have names array)
109
- next unless collection_name == "prefixes" && entity.respond_to?(:name) &&
110
- entity.name.to_s.downcase.include?(text.downcase)
111
-
112
- results << entity
113
- next
97
+ scope.each_with_object([]) do |name, results|
98
+ collection(name).each do |entity|
99
+ results << entity if matches_text?(entity, needle)
114
100
  end
115
101
  end
116
-
117
- results
118
102
  end
119
103
 
120
104
  # Find entities by symbol
@@ -124,38 +108,16 @@ module Unitsdb
124
108
  def find_by_symbol(symbol, entity_type = nil)
125
109
  return [] unless symbol
126
110
 
127
- results = []
128
-
129
- # Symbol search only applies to units and prefixes
130
- collections = entity_type ? [entity_type.to_s] : %w[units prefixes]
131
-
132
- collections.each do |collection_name|
133
- next unless respond_to?(collection_name) && %w[units
134
- prefixes].include?(collection_name)
135
-
136
- collection = send(collection_name)
137
- collection.each do |entity|
138
- if collection_name == "units" && entity.respond_to?(:symbols) && entity.symbols
139
- # Units can have multiple symbols
140
- matches = entity.symbols.any? do |sym|
141
- sym.respond_to?(:ascii) && sym.ascii &&
142
- sym.ascii.downcase == symbol.downcase
143
- end
144
-
145
- results << entity if matches
146
- elsif collection_name == "prefixes" && entity.respond_to?(:symbols) && entity.symbols
147
- # Prefixes have multiple symbols in 2.0.0
148
- matches = entity.symbols.any? do |sym|
149
- sym.respond_to?(:ascii) && sym.ascii &&
150
- sym.ascii.downcase == symbol.downcase
151
- end
152
-
153
- results << entity if matches
111
+ needle = symbol.downcase
112
+ scope = scope_for(entity_type, SYMBOL_COLLECTIONS)
113
+
114
+ scope.each_with_object([]) do |name, results|
115
+ collection(name).each do |entity|
116
+ results << entity if entity.symbols.any? do |sym|
117
+ sym.ascii.to_s.downcase == needle
154
118
  end
155
119
  end
156
120
  end
157
-
158
- results
159
121
  end
160
122
 
161
123
  # Match entities by name, short, or symbol with different match types
@@ -166,530 +128,108 @@ module Unitsdb
166
128
  # @return [Hash] matches grouped by match type (exact, symbol_match) with match details
167
129
  def match_entities(params = {})
168
130
  value = params[:value]
169
- match_type = params[:match_type]&.to_s || "exact"
170
- entity_type = params[:entity_type]
171
-
172
131
  return {} unless value
173
132
 
174
- result = {
175
- exact: [],
176
- symbol_match: [],
177
- }
178
-
179
- # Define collections to search based on entity_type parameter
180
- collections = if entity_type
181
- [entity_type.to_s]
182
- else
183
- %w[units prefixes
184
- quantities dimensions unit_systems]
185
- end
186
-
187
- collections.each do |collection_name|
188
- next unless respond_to?(collection_name)
189
-
190
- collection = send(collection_name)
133
+ match_type = params[:match_type]&.to_s || "exact"
134
+ result = { exact: [], symbol_match: [] }
191
135
 
192
- collection.each do |entity|
193
- # For exact matches - look at short and names
136
+ scope_for(params[:entity_type], COLLECTIONS).each do |name|
137
+ collection(name).each do |entity|
194
138
  if %w[exact all].include?(match_type)
195
- # Match by short
196
- if entity.respond_to?(:short) && entity.short &&
197
- entity.short.downcase == value.downcase
198
- result[:exact] << {
199
- entity: entity,
200
- match_desc: "short_to_name",
201
- details: "UnitsDB short '#{entity.short}' matches '#{value}'",
202
- }
203
- next
204
- end
205
-
206
- # Match by names
207
- if entity.respond_to?(:names) && entity.names
208
- matching_name = entity.names.find do |name|
209
- name.value.to_s.downcase == value.downcase
210
- end
211
- if matching_name
212
- result[:exact] << {
213
- entity: entity,
214
- match_desc: "name_to_name",
215
- details: "UnitsDB name '#{matching_name.value}' (#{matching_name.lang}) matches '#{value}'",
216
- }
217
- next
218
- end
219
- end
139
+ match_exact(entity, value, result)
220
140
  end
141
+ next unless %w[symbol all].include?(match_type) && SYMBOL_COLLECTIONS.include?(name)
221
142
 
222
- # For symbol matches - only applicable to units and prefixes
223
- if %w[symbol all].include?(match_type) &&
224
- %w[units prefixes].include?(collection_name)
225
- if collection_name == "units" && entity.respond_to?(:symbols) && entity.symbols
226
- # Units can have multiple symbols
227
- matching_symbol = entity.symbols.find do |sym|
228
- sym.respond_to?(:ascii) && sym.ascii &&
229
- sym.ascii.downcase == value.downcase
230
- end
231
-
232
- if matching_symbol
233
- result[:symbol_match] << {
234
- entity: entity,
235
- match_desc: "symbol_match",
236
- details: "UnitsDB symbol '#{matching_symbol.ascii}' matches '#{value}'",
237
- }
238
- end
239
- elsif collection_name == "prefixes" && entity.respond_to?(:symbols) && entity.symbols
240
- # Prefixes have multiple symbols in 2.0.0
241
- matching_symbol = entity.symbols.find do |sym|
242
- sym.respond_to?(:ascii) && sym.ascii &&
243
- sym.ascii.downcase == value.downcase
244
- end
245
-
246
- if matching_symbol
247
- result[:symbol_match] << {
248
- entity: entity,
249
- match_desc: "symbol_match",
250
- details: "UnitsDB symbol '#{matching_symbol.ascii}' matches '#{value}'",
251
- }
252
- end
253
- end
254
- end
143
+ match_symbol(entity, value, result)
255
144
  end
256
145
  end
257
146
 
258
- # Remove empty categories
259
147
  result.delete_if { |_, v| v.empty? }
260
-
261
148
  result
262
149
  end
263
150
 
264
- # Checks for uniqueness of identifiers and short names
151
+ # Checks for uniqueness of identifiers and short names.
152
+ # Delegates to UniquenessValidator.
265
153
  def validate_uniqueness
266
- results = {
267
- short: {},
268
- id: {},
269
- }
270
-
271
- # Validate short names for applicable collections
272
- validate_shorts(units, "units", results)
273
- validate_shorts(dimensions, "dimensions", results)
274
- validate_shorts(unit_systems, "unit_systems", results)
275
-
276
- # Validate identifiers for all collections
277
- validate_identifiers(units, "units", results)
278
- validate_identifiers(prefixes, "prefixes", results)
279
- validate_identifiers(quantities, "quantities", results)
280
- validate_identifiers(dimensions, "dimensions", results)
281
- validate_identifiers(unit_systems, "unit_systems", results)
282
-
283
- results
154
+ UniquenessValidator.validate(self)
284
155
  end
285
156
 
286
- # Validates references between entities
157
+ # Validates references between entities. Delegates to ReferenceValidator.
287
158
  def validate_references
288
- invalid_refs = {}
289
-
290
- # Build registry of all valid IDs first
291
- registry = build_id_registry
292
-
293
- # Check various reference types
294
- check_dimension_references(registry, invalid_refs)
295
- check_unit_system_references(registry, invalid_refs)
296
- check_quantity_references(registry, invalid_refs)
297
- check_root_unit_references(registry, invalid_refs)
298
-
299
- invalid_refs
300
- end
301
-
302
- def self.from_db(dir_path, context: Unitsdb::Config.context_id)
303
- context_id = context.to_sym
304
- if context_id == Unitsdb::Config.context_id &&
305
- Unitsdb::Config.find_context(context_id).nil?
306
- Unitsdb::Config.context(context_id)
307
- end
308
-
309
- db_path = File.expand_path(dir_path.to_s)
310
- unless Dir.exist?(db_path)
311
- raise Errors::DatabaseNotFoundError,
312
- "Database directory not found: #{db_path}"
313
- end
314
-
315
- missing_files = DATABASE_FILES.values.reject do |filename|
316
- File.exist?(File.join(db_path, filename))
317
- end
318
-
319
- if missing_files.any?
320
- raise Errors::DatabaseFileNotFoundError,
321
- "Missing required database files: #{missing_files.join(', ')}"
322
- end
323
-
324
- documents = load_database_documents(db_path)
325
- schema_version = validate_schema_versions!(documents)
326
- combined_hash = build_database_hash(documents, schema_version)
327
-
328
- Lutaml::Model::GlobalContext.with_context(context_id) do
329
- if Unitsdb::Config.register(context_id)
330
- from_hash(combined_hash, register: context_id)
331
- else
332
- from_hash(combined_hash)
333
- end
334
- end
335
- end
336
-
337
- def self.load_database_documents(db_path)
338
- puts "[UnitsDB] Loading YAML files from directory: #{db_path}" if ENV["UNITSDB_DEBUG"]
339
- DATABASE_FILES.transform_values do |filename|
340
- puts " - #{File.join(db_path, filename)}" if ENV["UNITSDB_DEBUG"]
341
- load_database_yaml(File.join(db_path, filename), filename)
342
- end
343
- end
344
-
345
- def self.load_database_yaml(path, filename)
346
- document = YAML.safe_load_file(path)
347
-
348
- unless document.is_a?(Hash)
349
- raise Errors::DatabaseFileInvalidError,
350
- "Invalid YAML structure in #{filename}: expected a mapping"
351
- end
352
-
353
- document
354
- rescue Errno::ENOENT => e
355
- raise Errors::DatabaseFileNotFoundError,
356
- "Failed to read database file: #{e.message}"
357
- rescue Psych::SyntaxError => e
358
- raise Errors::DatabaseFileInvalidError,
359
- "Invalid YAML in database file: #{e.message}"
360
- rescue Errors::DatabaseError
361
- raise
362
- rescue StandardError => e
363
- raise Errors::DatabaseLoadError,
364
- "Error loading database file #{filename}: #{e.message}"
365
- end
366
- private_class_method :load_database_documents, :load_database_yaml
367
-
368
- def self.validate_schema_versions!(documents)
369
- versions = DATABASE_FILES.each_with_object({}) do |(collection_key, filename), result|
370
- document = documents.fetch(collection_key)
371
- result[filename] = document.fetch("schema_version")
372
- rescue KeyError
373
- raise Errors::DatabaseFileInvalidError,
374
- "Missing schema_version in #{filename}"
375
- end
376
-
377
- unless versions.values.uniq.size == 1
378
- raise Errors::VersionMismatchError,
379
- "Version mismatch in database files: #{versions.inspect}"
380
- end
381
-
382
- version = versions.values.first
383
- unless version == SUPPORTED_SCHEMA_VERSION
384
- raise Errors::UnsupportedVersionError,
385
- "Unsupported database version: #{version}. Only version #{SUPPORTED_SCHEMA_VERSION} is supported."
386
- end
387
-
388
- version
159
+ ReferenceValidator.validate(self)
389
160
  end
390
161
 
391
- def self.build_database_hash(documents, schema_version)
392
- {
393
- "schema_version" => schema_version,
394
- }.merge(
395
- DATABASE_FILES.keys.to_h do |collection_key|
396
- document = documents.fetch(collection_key)
397
- [collection_key, fetch_collection!(document, collection_key)]
398
- end,
399
- )
400
- end
401
-
402
- def self.fetch_collection!(document, collection_key)
403
- document.fetch(collection_key)
404
- rescue KeyError
405
- raise Errors::DatabaseFileInvalidError,
406
- "Missing #{collection_key} collection in #{DATABASE_FILES.fetch(collection_key)}"
407
- end
408
- private_class_method :validate_schema_versions!, :build_database_hash,
409
- :fetch_collection!
410
-
411
162
  private
412
163
 
413
- # Helper methods for uniqueness validation
414
- def validate_shorts(collection, type, results)
415
- shorts = {}
416
-
417
- collection.each_with_index do |item, index|
418
- next unless item.respond_to?(:short) && item.short
164
+ # Resolve a caller-supplied type to a list of collection symbols,
165
+ # validating against `allowed`. Nil means "all allowed".
166
+ def scope_for(type, allowed)
167
+ return allowed.dup unless type
419
168
 
420
- (shorts[item.short] ||= []) << "index:#{index}"
421
- end
422
-
423
- # Add to results if duplicates found
424
- shorts.each do |short, paths|
425
- next unless paths.size > 1
169
+ sym = type.to_sym
170
+ raise ArgumentError, "unknown collection: #{type.inspect}" unless allowed.include?(sym)
426
171
 
427
- (results[:short][type] ||= {})[short] = paths
428
- end
172
+ [sym]
429
173
  end
430
174
 
431
- def validate_identifiers(collection, type, results)
432
- ids = {}
433
-
434
- collection.each_with_index do |item, index|
435
- next unless item.respond_to?(:identifiers)
436
-
437
- # Process identifiers array for this item
438
- item.identifiers.each_with_index do |identifier, id_index|
439
- next unless identifier.respond_to?(:id) && identifier.id
440
-
441
- id_key = identifier.id
442
- loc = "index:#{index}:identifiers[#{id_index}]"
443
- (ids[id_key] ||= []) << loc
444
- end
445
- end
446
-
447
- # Add duplicates to results
448
- ids.each do |id, paths|
449
- unique_paths = paths.uniq
450
- next unless unique_paths.size > 1
451
-
452
- (results[:id][type] ||= {})[id] = unique_paths
453
- end
175
+ def matches_text?(entity, needle)
176
+ entity.identifiers.any? { |i| i.id.to_s.downcase.include?(needle) } ||
177
+ entity.names.any? { |n| n.value.to_s.downcase.include?(needle) } ||
178
+ entity.short.to_s.downcase.include?(needle)
454
179
  end
455
180
 
456
- # Helper methods for reference validation
457
- def build_id_registry
458
- registry = {}
459
-
460
- # Add unit identifiers
461
- registry["units"] = {}
462
- units.each_with_index do |unit, index|
463
- next unless unit.respond_to?(:identifiers)
464
-
465
- unit.identifiers.each do |identifier|
466
- next unless identifier.id && identifier.type
467
-
468
- # Add composite key (type:id)
469
- composite_key = "#{identifier.type}:#{identifier.id}"
470
- registry["units"][composite_key] = "index:#{index}"
471
-
472
- # Also add just the ID for backward compatibility
473
- registry["units"][identifier.id] = "index:#{index}"
474
- end
475
- end
476
-
477
- # Add dimension identifiers
478
- registry["dimensions"] = {}
479
- dimensions.each_with_index do |dimension, index|
480
- next unless dimension.respond_to?(:identifiers)
481
-
482
- dimension.identifiers.each do |identifier|
483
- next unless identifier.id && identifier.type
484
-
485
- composite_key = "#{identifier.type}:#{identifier.id}"
486
- registry["dimensions"][composite_key] = "index:#{index}"
487
- registry["dimensions"][identifier.id] = "index:#{index}"
488
- end
489
-
490
- # Also track dimensions by short name
491
- if dimension.respond_to?(:short) && dimension.short
492
- registry["dimensions_short"] ||= {}
493
- registry["dimensions_short"][dimension.short] = "index:#{index}"
494
- end
495
- end
496
-
497
- # Add quantity identifiers
498
- registry["quantities"] = {}
499
- quantities.each_with_index do |quantity, index|
500
- next unless quantity.respond_to?(:identifiers)
501
-
502
- quantity.identifiers.each do |identifier|
503
- next unless identifier.id && identifier.type
504
-
505
- composite_key = "#{identifier.type}:#{identifier.id}"
506
- registry["quantities"][composite_key] = "index:#{index}"
507
- registry["quantities"][identifier.id] = "index:#{index}"
508
- end
509
- end
510
-
511
- # Add prefix identifiers
512
- registry["prefixes"] = {}
513
- prefixes.each_with_index do |prefix, index|
514
- next unless prefix.respond_to?(:identifiers)
515
-
516
- prefix.identifiers.each do |identifier|
517
- next unless identifier.id && identifier.type
518
-
519
- composite_key = "#{identifier.type}:#{identifier.id}"
520
- registry["prefixes"][composite_key] = "index:#{index}"
521
- registry["prefixes"][identifier.id] = "index:#{index}"
522
- end
181
+ def match_exact(entity, value, result)
182
+ if entity.short && entity.short.downcase == value.downcase
183
+ result[:exact] << {
184
+ entity: entity,
185
+ match_desc: "short_to_name",
186
+ details: "UnitsDB short '#{entity.short}' matches '#{value}'",
187
+ }
188
+ return
523
189
  end
524
190
 
525
- # Add unit system identifiers
526
- registry["unit_systems"] = {}
527
- unit_systems.each_with_index do |unit_system, index|
528
- next unless unit_system.respond_to?(:identifiers)
529
-
530
- unit_system.identifiers.each do |identifier|
531
- next unless identifier.id && identifier.type
532
-
533
- composite_key = "#{identifier.type}:#{identifier.id}"
534
- registry["unit_systems"][composite_key] = "index:#{index}"
535
- registry["unit_systems"][identifier.id] = "index:#{index}"
536
- end
537
-
538
- # Also track unit systems by short name
539
- if unit_system.respond_to?(:short) && unit_system.short
540
- registry["unit_systems_short"] ||= {}
541
- registry["unit_systems_short"][unit_system.short] = "index:#{index}"
542
- end
191
+ matching_name = entity.names.find do |name|
192
+ name.value.to_s.downcase == value.downcase
543
193
  end
194
+ return unless matching_name
544
195
 
545
- registry
546
- end
547
-
548
- def check_dimension_references(registry, invalid_refs)
549
- dimensions.each_with_index do |dimension, index|
550
- next unless dimension.respond_to?(:dimension_reference) && dimension.dimension_reference
551
-
552
- ref_id = dimension.dimension_reference
553
- ref_type = "dimensions"
554
- ref_path = "dimensions:index:#{index}:dimension_reference"
555
-
556
- validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs,
557
- "dimensions")
558
- end
196
+ result[:exact] << {
197
+ entity: entity,
198
+ match_desc: "name_to_name",
199
+ details: "UnitsDB name '#{matching_name.value}' (#{matching_name.lang}) matches '#{value}'",
200
+ }
559
201
  end
560
202
 
561
- def check_unit_system_references(registry, invalid_refs)
562
- units.each_with_index do |unit, index|
563
- next unless unit.respond_to?(:unit_system_reference) && unit.unit_system_reference
564
-
565
- unit.unit_system_reference.each_with_index do |ref_id, idx|
566
- ref_type = "unit_systems"
567
- ref_path = "units:index:#{index}:unit_system_reference[#{idx}]"
568
-
569
- validate_reference(ref_id, ref_type, ref_path, registry,
570
- invalid_refs, "units")
571
- end
203
+ def match_symbol(entity, value, result)
204
+ matching_symbol = entity.symbols.find do |sym|
205
+ sym.ascii.to_s.downcase == value.downcase
572
206
  end
573
- end
574
-
575
- def check_quantity_references(registry, invalid_refs)
576
- units.each_with_index do |unit, index|
577
- next unless unit.respond_to?(:quantity_references) && unit.quantity_references
207
+ return unless matching_symbol
578
208
 
579
- unit.quantity_references.each_with_index do |ref_id, idx|
580
- ref_type = "quantities"
581
- ref_path = "units:index:#{index}:quantity_references[#{idx}]"
582
-
583
- validate_reference(ref_id, ref_type, ref_path, registry,
584
- invalid_refs, "units")
585
- end
586
- end
587
- end
588
-
589
- def check_root_unit_references(registry, invalid_refs)
590
- units.each_with_index do |unit, index|
591
- next unless unit.respond_to?(:root_units) && unit.root_units
592
-
593
- unit.root_units.each_with_index do |root_unit, idx|
594
- next unless root_unit.respond_to?(:unit_reference) && root_unit.unit_reference
595
-
596
- # Check unit reference
597
- ref_id = root_unit.unit_reference
598
- ref_type = "units"
599
- ref_path = "units:index:#{index}:root_units.#{idx}.unit_reference"
600
-
601
- validate_reference(ref_id, ref_type, ref_path, registry,
602
- invalid_refs, "units")
603
-
604
- # Check prefix reference if present
605
- next unless root_unit.respond_to?(:prefix_reference) && root_unit.prefix_reference
606
-
607
- ref_id = root_unit.prefix_reference
608
- ref_type = "prefixes"
609
- ref_path = "units:index:#{index}:root_units.#{idx}.prefix_reference"
610
-
611
- validate_reference(ref_id, ref_type, ref_path, registry,
612
- invalid_refs, "units")
613
- end
614
- end
209
+ result[:symbol_match] << {
210
+ entity: entity,
211
+ match_desc: "symbol_match",
212
+ details: "UnitsDB symbol '#{matching_symbol.ascii}' matches '#{value}'",
213
+ }
615
214
  end
616
215
 
617
- def validate_reference(ref_id, ref_type, ref_path, registry, invalid_refs, file_type)
618
- # Handle references that are objects with id and type (could be a hash or an object)
619
- if ref_id.respond_to?(:id) && ref_id.respond_to?(:type)
620
- id = ref_id.id
621
- type = ref_id.type
622
- composite_key = "#{type}:#{id}"
623
-
624
- # Try multiple lookup strategies
625
- valid = false
626
-
627
- # 1. Try exact composite key match
628
- valid = true if registry.key?(ref_type) && registry[ref_type].key?(composite_key)
629
-
630
- # 2. Try just ID match if composite didn't work
631
- valid = true if !valid && registry.key?(ref_type) && registry[ref_type].key?(id)
632
-
633
- # 3. Try alternate ID formats for unit systems (e.g., SI_base vs si-base)
634
- if !valid && type == "unitsml" && ref_type == "unit_systems" && registry.key?(ref_type) && (
635
- registry[ref_type].keys.any? { |k| k.end_with?(":#{id}") } ||
636
- registry[ref_type].keys.any? do |k|
637
- k.end_with?(":SI_#{id.sub('si-', '')}")
638
- end ||
639
- registry[ref_type].keys.any? do |k|
640
- k.end_with?(":non-SI_#{id.sub('nonsi-', '')}")
641
- end
642
- )
643
- # Special handling for unit_systems between unitsml and nist types
644
- valid = true
645
- end
216
+ class << self
217
+ # Load every YAML file under `dir_path` and deserialize into a
218
+ # Database instance scoped to `context`. The default context is
219
+ # auto-created via `Config.ensure_default_context!`; custom
220
+ # contexts must be created by the caller first.
221
+ def from_db(dir_path, context: Unitsdb::Config.context_id)
222
+ context_id = context.to_sym
223
+ Unitsdb::Config.ensure_default_context! if context_id == Unitsdb::Config.context_id
646
224
 
647
- unless valid
648
- invalid_refs[file_type] ||= {}
649
- invalid_refs[file_type][ref_path] =
650
- { id: id, type: type, ref_type: ref_type }
651
- end
652
- # Handle references that are objects with id and type in a hash
653
- elsif ref_id.is_a?(Hash) && ref_id.key?("id") && ref_id.key?("type")
654
- id = ref_id["id"]
655
- type = ref_id["type"]
656
- composite_key = "#{type}:#{id}"
657
-
658
- # Try multiple lookup strategies
659
- valid = false
660
-
661
- # 1. Try exact composite key match
662
- valid = true if registry.key?(ref_type) && registry[ref_type].key?(composite_key)
663
-
664
- # 2. Try just ID match if composite didn't work
665
- valid = true if !valid && registry.key?(ref_type) && registry[ref_type].key?(id)
666
-
667
- # 3. Try alternate ID formats for unit systems (e.g., SI_base vs si-base)
668
- if !valid && type == "unitsml" && ref_type == "unit_systems" && registry.key?(ref_type) && (
669
- registry[ref_type].keys.any? { |k| k.end_with?(":#{id}") } ||
670
- registry[ref_type].keys.any? do |k|
671
- k.end_with?(":SI_#{id.sub('si-', '')}")
672
- end ||
673
- registry[ref_type].keys.any? do |k|
674
- k.end_with?(":non-SI_#{id.sub('nonsi-', '')}")
675
- end
676
- )
677
- # Special handling for unit_systems between unitsml and nist types
678
- valid = true
679
- end
680
-
681
- unless valid
682
- invalid_refs[file_type] ||= {}
683
- invalid_refs[file_type][ref_path] =
684
- { id: id, type: type, ref_type: ref_type }
685
- end
686
- else
687
- # Handle plain string references (legacy format)
688
- valid = registry.key?(ref_type) && registry[ref_type].key?(ref_id)
225
+ combined_hash = Loader.load(dir_path)
689
226
 
690
- unless valid
691
- invalid_refs[file_type] ||= {}
692
- invalid_refs[file_type][ref_path] = { id: ref_id, type: ref_type }
227
+ Lutaml::Model::GlobalContext.with_context(context_id) do
228
+ if Unitsdb::Config.register_id_for(context_id)
229
+ from_hash(combined_hash, register: context_id)
230
+ else
231
+ from_hash(combined_hash)
232
+ end
693
233
  end
694
234
  end
695
235
  end