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