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.
- checksums.yaml +4 -4
- data/.github/workflows/opal.yml +36 -0
- data/.gitignore +3 -0
- data/Gemfile +2 -1
- data/Rakefile +3 -1
- data/lib/unitsdb/cli.rb +5 -41
- data/lib/unitsdb/commands/_modify.rb +1 -34
- data/lib/unitsdb/commands/check_si/si_formatter.rb +6 -6
- data/lib/unitsdb/commands/check_si/si_matcher.rb +202 -292
- data/lib/unitsdb/commands/check_si/si_updater.rb +16 -36
- data/lib/unitsdb/commands/entity_presenter.rb +98 -0
- data/lib/unitsdb/commands/get.rb +16 -113
- data/lib/unitsdb/commands/qudt/formatter.rb +16 -27
- data/lib/unitsdb/commands/qudt/matcher.rb +18 -28
- data/lib/unitsdb/commands/qudt/updater.rb +8 -11
- data/lib/unitsdb/commands/qudt.rb +1 -34
- data/lib/unitsdb/commands/search.rb +33 -188
- data/lib/unitsdb/commands/thor.rb +41 -0
- data/lib/unitsdb/commands/ucum/formatter.rb +9 -18
- data/lib/unitsdb/commands/ucum/matcher.rb +4 -4
- data/lib/unitsdb/commands/ucum/updater.rb +3 -5
- data/lib/unitsdb/commands/ucum.rb +1 -34
- data/lib/unitsdb/commands/validate/qudt_references.rb +29 -70
- data/lib/unitsdb/commands/validate/references.rb +5 -303
- data/lib/unitsdb/commands/validate/si_references.rb +30 -66
- data/lib/unitsdb/commands/validate/ucum_references.rb +30 -64
- data/lib/unitsdb/commands/validate.rb +1 -36
- data/lib/unitsdb/commands.rb +2 -0
- data/lib/unitsdb/config.rb +83 -29
- data/lib/unitsdb/database/loader.rb +135 -0
- data/lib/unitsdb/database/reference_validator.rb +227 -0
- data/lib/unitsdb/database/uniqueness_validator.rb +80 -0
- data/lib/unitsdb/database.rb +124 -584
- data/lib/unitsdb/dimension.rb +0 -27
- data/lib/unitsdb/dimensions.rb +0 -2
- data/lib/unitsdb/opal.rb +43 -0
- data/lib/unitsdb/prefix.rb +0 -13
- data/lib/unitsdb/prefixes.rb +0 -2
- data/lib/unitsdb/quantities.rb +0 -1
- data/lib/unitsdb/quantity.rb +0 -2
- data/lib/unitsdb/quantity_reference.rb +0 -2
- data/lib/unitsdb/root_unit_reference.rb +0 -2
- data/lib/unitsdb/scale.rb +0 -2
- data/lib/unitsdb/scale_properties.rb +0 -1
- data/lib/unitsdb/scales.rb +0 -1
- data/lib/unitsdb/si_derived_base.rb +0 -1
- data/lib/unitsdb/symbol_presentations.rb +0 -2
- data/lib/unitsdb/unit.rb +0 -34
- data/lib/unitsdb/unit_system.rb +0 -2
- data/lib/unitsdb/unit_systems.rb +0 -2
- data/lib/unitsdb/units.rb +0 -2
- data/lib/unitsdb/version.rb +1 -1
- data/lib/unitsdb.rb +142 -35
- data/unitsdb.gemspec +1 -0
- metadata +23 -2
data/lib/unitsdb/database.rb
CHANGED
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module Unitsdb
|
|
4
4
|
class Database < Lutaml::Model::Serializable
|
|
5
|
-
|
|
5
|
+
autoload :ReferenceValidator, "unitsdb/database/reference_validator"
|
|
6
|
+
autoload :UniquenessValidator, "unitsdb/database/uniqueness_validator"
|
|
7
|
+
autoload :Loader, "unitsdb/database/loader"
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
collection.each do |entity|
|
|
85
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
428
|
-
end
|
|
172
|
+
[sym]
|
|
429
173
|
end
|
|
430
174
|
|
|
431
|
-
def
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
618
|
-
#
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|