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/config.rb
CHANGED
|
@@ -5,10 +5,18 @@ module Unitsdb
|
|
|
5
5
|
CONTEXT_ID = :unitsdb_v2
|
|
6
6
|
|
|
7
7
|
class << self
|
|
8
|
+
# The currently-active default context id. Config-populated
|
|
9
|
+
# contexts are created on demand under this id.
|
|
8
10
|
def context_id
|
|
9
11
|
@context_id ||= CONTEXT_ID
|
|
10
12
|
end
|
|
11
13
|
|
|
14
|
+
# ---------------------------------------------------------------
|
|
15
|
+
# Model registry
|
|
16
|
+
# ---------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
# Register a model class under `id`. Single registration API;
|
|
19
|
+
# `models=` is a thin enumerator over this.
|
|
12
20
|
def register_model(klass, id:)
|
|
13
21
|
registered_models[id.to_sym] = klass
|
|
14
22
|
klass
|
|
@@ -18,28 +26,34 @@ module Unitsdb
|
|
|
18
26
|
@registered_models ||= {}
|
|
19
27
|
end
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
end
|
|
24
|
-
|
|
29
|
+
# Bulk-register models from a hash. Reads back through
|
|
30
|
+
# `register_model` so there is a single source of truth.
|
|
25
31
|
def models=(user_models)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
result[model_id] = register_model(klass, id: model_id)
|
|
32
|
+
user_models.each do |id, klass|
|
|
33
|
+
register_model(klass, id: id.to_sym)
|
|
29
34
|
end
|
|
30
|
-
|
|
31
|
-
models.merge!(normalized_models)
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
# Look up a registered model by id. Kept as a stable public
|
|
38
|
+
# API for downstream gems (e.g. unitsml) that previously used
|
|
39
|
+
# the Configuration module.
|
|
34
40
|
def model_for(model_name)
|
|
35
|
-
|
|
36
|
-
models[model_id] || registered_models[model_id]
|
|
41
|
+
registered_models[model_name.to_sym]
|
|
37
42
|
end
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
# ---------------------------------------------------------------
|
|
45
|
+
# Lutaml register bridge (opt-in)
|
|
46
|
+
# ---------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
# Look up the Lutaml::Model::Register id (or nil) that was
|
|
49
|
+
# explicitly created for `context_id` via `populate_register`.
|
|
50
|
+
def register_id_for(context_id = context_id())
|
|
51
|
+
explicit_registers[context_id.to_sym]
|
|
41
52
|
end
|
|
42
53
|
|
|
54
|
+
# Create a Lutaml::Model::Register for `id`, enabling
|
|
55
|
+
# `from_hash(register: id)` deserialization. Power-user API —
|
|
56
|
+
# most callers want `populate_context` only.
|
|
43
57
|
def populate_register(id: context_id, fallback_to: [:default], substitutions: [])
|
|
44
58
|
register_id = id.to_sym
|
|
45
59
|
context(register_id)
|
|
@@ -57,6 +71,14 @@ module Unitsdb
|
|
|
57
71
|
explicit_registers[register_id] = Lutaml::Model::GlobalRegister.register(model_register)
|
|
58
72
|
end
|
|
59
73
|
|
|
74
|
+
def explicit_registers
|
|
75
|
+
@explicit_registers ||= {}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------
|
|
79
|
+
# Context lifecycle
|
|
80
|
+
# ---------------------------------------------------------------
|
|
81
|
+
|
|
60
82
|
def find_context(id)
|
|
61
83
|
Lutaml::Model::GlobalContext.context(id.to_sym)
|
|
62
84
|
end
|
|
@@ -65,25 +87,40 @@ module Unitsdb
|
|
|
65
87
|
Lutaml::Model::GlobalContext.resolve_type(type_name, context.to_sym)
|
|
66
88
|
end
|
|
67
89
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
# Return the context for `id`, creating it via `populate_context`
|
|
91
|
+
# when missing. Non-destructive: existing contexts (whether
|
|
92
|
+
# Config-created or externally-managed) are returned as-is.
|
|
93
|
+
# Use `populate_context` directly to force-rebuild.
|
|
94
|
+
def context(id = context_id())
|
|
95
|
+
find_context(id) || populate_context(id: id)
|
|
73
96
|
end
|
|
74
97
|
|
|
75
|
-
|
|
98
|
+
# Force-create a context under `id`, replacing any prior
|
|
99
|
+
# context (owned or external).
|
|
100
|
+
def populate_context(id: context_id, fallback_to: [:default],
|
|
101
|
+
substitutions: [])
|
|
76
102
|
Lutaml::Model::GlobalContext.unregister_context(id) if find_context(id)
|
|
77
103
|
|
|
78
104
|
opts = { registry: build_registry, fallback_to: fallback_to, id: id }
|
|
79
|
-
|
|
105
|
+
Lutaml::Model::GlobalContext.create_context(
|
|
80
106
|
substitutions: resolve_substitutions(substitutions, **opts),
|
|
81
107
|
**opts,
|
|
82
108
|
)
|
|
83
|
-
mark_populated!(id)
|
|
84
|
-
context
|
|
85
109
|
end
|
|
86
110
|
|
|
111
|
+
# Convenience: ensure the default context exists. Idempotent.
|
|
112
|
+
# Used as the single bootstrap site for `Unitsdb.database` and
|
|
113
|
+
# `Database.from_db`.
|
|
114
|
+
def ensure_default_context!
|
|
115
|
+
return if find_context(context_id)
|
|
116
|
+
|
|
117
|
+
populate_context(id: context_id)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------
|
|
121
|
+
# Substitution resolution
|
|
122
|
+
# ---------------------------------------------------------------
|
|
123
|
+
|
|
87
124
|
def resolve_substitutions(substitutions, registry:, fallback_to:, id:)
|
|
88
125
|
resolution_context = Lutaml::Model::TypeContext.derived(
|
|
89
126
|
id: "#{id}_substitution_resolution",
|
|
@@ -109,22 +146,39 @@ module Unitsdb
|
|
|
109
146
|
end
|
|
110
147
|
|
|
111
148
|
def build_registry
|
|
149
|
+
Unitsdb.eager_load_models!
|
|
112
150
|
registry = Lutaml::Model::TypeRegistry.new
|
|
113
151
|
registered_models.each { |model_id, klass| registry.register(model_id, klass) }
|
|
114
152
|
registry
|
|
115
153
|
end
|
|
116
154
|
|
|
117
|
-
|
|
118
|
-
|
|
155
|
+
# ---------------------------------------------------------------
|
|
156
|
+
# Test support — snapshot/restore/isolate
|
|
157
|
+
# ---------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def capture_state
|
|
160
|
+
{
|
|
161
|
+
registered_models: registered_models.dup,
|
|
162
|
+
explicit_registers: explicit_registers.dup,
|
|
163
|
+
context_id: @context_id,
|
|
164
|
+
}
|
|
119
165
|
end
|
|
120
166
|
|
|
121
|
-
def
|
|
122
|
-
@
|
|
123
|
-
@
|
|
167
|
+
def restore_state(snapshot)
|
|
168
|
+
@registered_models = snapshot[:registered_models]
|
|
169
|
+
@explicit_registers = snapshot[:explicit_registers]
|
|
170
|
+
@context_id = snapshot[:context_id]
|
|
124
171
|
end
|
|
125
172
|
|
|
126
|
-
|
|
127
|
-
|
|
173
|
+
# Run a block against a fresh Lutaml global context, then
|
|
174
|
+
# restore Config state so subsequent specs see bundled defaults.
|
|
175
|
+
def with_isolated_config
|
|
176
|
+
snapshot = capture_state
|
|
177
|
+
Lutaml::Model::GlobalContext.reset!
|
|
178
|
+
yield
|
|
179
|
+
ensure
|
|
180
|
+
restore_state(snapshot) if snapshot
|
|
181
|
+
Lutaml::Model::GlobalContext.reset!
|
|
128
182
|
end
|
|
129
183
|
end
|
|
130
184
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Unitsdb
|
|
6
|
+
class Database
|
|
7
|
+
# Filesystem and YAML-schema layer of Database. Reads the
|
|
8
|
+
# collection YAML files from a directory, validates their schema
|
|
9
|
+
# versions agree, and returns a combined hash ready for
|
|
10
|
+
# Lutaml::Model deserialization. Knows nothing about contexts or
|
|
11
|
+
# registers — that's `Database.from_db`'s job.
|
|
12
|
+
class Loader
|
|
13
|
+
DATABASE_FILES = {
|
|
14
|
+
"prefixes" => "prefixes.yaml",
|
|
15
|
+
"dimensions" => "dimensions.yaml",
|
|
16
|
+
"units" => "units.yaml",
|
|
17
|
+
"quantities" => "quantities.yaml",
|
|
18
|
+
"unit_systems" => "unit_systems.yaml",
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
SUPPORTED_SCHEMA_VERSION = "2.0.0"
|
|
22
|
+
|
|
23
|
+
def self.load(dir_path)
|
|
24
|
+
new(dir_path).load
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(dir_path)
|
|
28
|
+
@dir_path = File.expand_path(dir_path.to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Read every YAML file under @dir_path, validate schema versions,
|
|
32
|
+
# and return a combined hash keyed by collection name.
|
|
33
|
+
def load
|
|
34
|
+
verify_directory!
|
|
35
|
+
documents = read_documents
|
|
36
|
+
schema_version = validate_schema_versions!(documents)
|
|
37
|
+
build_database_hash(documents, schema_version)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def verify_directory!
|
|
43
|
+
unless Dir.exist?(@dir_path)
|
|
44
|
+
raise Errors::DatabaseNotFoundError,
|
|
45
|
+
"Database directory not found: #{@dir_path}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
missing = DATABASE_FILES.values.reject do |filename|
|
|
49
|
+
File.exist?(File.join(@dir_path, filename))
|
|
50
|
+
end
|
|
51
|
+
return if missing.empty?
|
|
52
|
+
|
|
53
|
+
raise Errors::DatabaseFileNotFoundError,
|
|
54
|
+
"Missing required database files: #{missing.join(', ')}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def read_documents
|
|
58
|
+
if ENV["UNITSDB_DEBUG"]
|
|
59
|
+
puts "[UnitsDB] Loading YAML files from directory: #{@dir_path}"
|
|
60
|
+
end
|
|
61
|
+
DATABASE_FILES.transform_values do |filename|
|
|
62
|
+
path = File.join(@dir_path, filename)
|
|
63
|
+
puts " - #{path}" if ENV["UNITSDB_DEBUG"]
|
|
64
|
+
read_yaml(path, filename)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def read_yaml(path, filename)
|
|
69
|
+
document = YAML.safe_load_file(path)
|
|
70
|
+
unless document.is_a?(Hash)
|
|
71
|
+
raise Errors::DatabaseFileInvalidError,
|
|
72
|
+
"Invalid YAML structure in #{filename}: expected a mapping"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
document
|
|
76
|
+
rescue Errno::ENOENT => e
|
|
77
|
+
raise Errors::DatabaseFileNotFoundError,
|
|
78
|
+
"Failed to read database file: #{e.message}"
|
|
79
|
+
rescue Psych::SyntaxError => e
|
|
80
|
+
raise Errors::DatabaseFileInvalidError,
|
|
81
|
+
"Invalid YAML in database file: #{e.message}"
|
|
82
|
+
rescue Errors::DatabaseError
|
|
83
|
+
raise
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
raise Errors::DatabaseLoadError,
|
|
86
|
+
"Error loading database file #{filename}: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_schema_versions!(documents)
|
|
90
|
+
versions = DATABASE_FILES.each_with_object({}) do |(collection_key, filename), result|
|
|
91
|
+
document = documents.fetch(collection_key)
|
|
92
|
+
result[filename] = document.fetch("schema_version")
|
|
93
|
+
rescue KeyError
|
|
94
|
+
raise Errors::DatabaseFileInvalidError,
|
|
95
|
+
"Missing schema_version in #{filename}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
unless versions.values.uniq.size == 1
|
|
99
|
+
raise Errors::VersionMismatchError,
|
|
100
|
+
"Version mismatch in database files: #{versions.inspect}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
version = versions.values.first
|
|
104
|
+
return version if version == SUPPORTED_SCHEMA_VERSION
|
|
105
|
+
|
|
106
|
+
raise Errors::UnsupportedVersionError,
|
|
107
|
+
"Unsupported database version: #{version}. " \
|
|
108
|
+
"Only version #{SUPPORTED_SCHEMA_VERSION} is supported."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_database_hash(documents, schema_version)
|
|
112
|
+
{
|
|
113
|
+
"schema_version" => schema_version,
|
|
114
|
+
}.merge(
|
|
115
|
+
DATABASE_FILES.keys.to_h do |collection_key|
|
|
116
|
+
document = documents.fetch(collection_key)
|
|
117
|
+
[collection_key, fetch_collection!(document, collection_key)]
|
|
118
|
+
end,
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def fetch_collection!(document, collection_key)
|
|
123
|
+
document.fetch(collection_key)
|
|
124
|
+
rescue KeyError
|
|
125
|
+
raise Errors::DatabaseFileInvalidError,
|
|
126
|
+
"Missing #{collection_key} collection in #{DATABASE_FILES.fetch(collection_key)}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Backwards-compat aliases — external callers (and the spec) read
|
|
131
|
+
# these constants off Database directly.
|
|
132
|
+
DATABASE_FILES = Loader::DATABASE_FILES
|
|
133
|
+
SUPPORTED_SCHEMA_VERSION = Loader::SUPPORTED_SCHEMA_VERSION
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unitsdb
|
|
4
|
+
class Database
|
|
5
|
+
# Validates that every cross-entity reference in a Database points
|
|
6
|
+
# at an entity that actually exists. Single source of truth for
|
|
7
|
+
# both `Database#validate_references` and the
|
|
8
|
+
# `unitsdb validate references` CLI command.
|
|
9
|
+
#
|
|
10
|
+
# Composition:
|
|
11
|
+
# ReferenceValidator — public façade; returns a Result
|
|
12
|
+
# IdRegistry — builds {type => {key => path}} from db
|
|
13
|
+
# ReferenceChecker — walks each reference kind
|
|
14
|
+
# LookupStrategies — pluggable "is this ref valid?" predicates
|
|
15
|
+
class ReferenceValidator
|
|
16
|
+
# @return [Hash] empty if all refs valid; otherwise
|
|
17
|
+
# { file_type => { ref_path => { id:, type:, ref_type: } } }
|
|
18
|
+
Result = Struct.new(:invalid, keyword_init: true) do
|
|
19
|
+
def empty?
|
|
20
|
+
invalid.empty?
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(database)
|
|
25
|
+
@database = database
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate
|
|
29
|
+
registry = IdRegistry.build(@database)
|
|
30
|
+
checker = ReferenceChecker.new(registry, LookupStrategies::ALL)
|
|
31
|
+
Result.new(invalid: checker.check_all(@database))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Convenience entry-point used by Database#validate_references.
|
|
35
|
+
def self.validate(database)
|
|
36
|
+
new(database).validate.invalid
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Builds a {collection_type => {ref_key => path}} registry
|
|
41
|
+
# covering identifiers (composite-keyed and bare-id keyed) plus
|
|
42
|
+
# short-name lookups for dimensions and unit_systems.
|
|
43
|
+
class IdRegistry
|
|
44
|
+
def self.build(database)
|
|
45
|
+
new(database).build
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(database)
|
|
49
|
+
@database = database
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build
|
|
53
|
+
registry = {}
|
|
54
|
+
Database::COLLECTIONS.each do |name|
|
|
55
|
+
registry[name.to_s] = {}
|
|
56
|
+
add_identifiers(registry, name)
|
|
57
|
+
end
|
|
58
|
+
add_short_names(registry, :dimensions, "dimensions_short")
|
|
59
|
+
add_short_names(registry, :unit_systems, "unit_systems_short")
|
|
60
|
+
registry
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def add_identifiers(registry, collection_name)
|
|
66
|
+
collection = @database.collection(collection_name)
|
|
67
|
+
collection.each_with_index do |entity, index|
|
|
68
|
+
entity.identifiers.each do |identifier|
|
|
69
|
+
next unless identifier.id && identifier.type
|
|
70
|
+
|
|
71
|
+
composite = "#{identifier.type}:#{identifier.id}"
|
|
72
|
+
registry[collection_name.to_s][composite] = "index:#{index}"
|
|
73
|
+
registry[collection_name.to_s][identifier.id] = "index:#{index}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def add_short_names(registry, collection_name, key)
|
|
79
|
+
registry[key.to_s] = {}
|
|
80
|
+
@database.collection(collection_name).each_with_index do |entity, idx|
|
|
81
|
+
next unless entity.short
|
|
82
|
+
|
|
83
|
+
registry[key.to_s][entity.short] = "index:#{idx}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Walks each reference kind in the Database and records invalid
|
|
89
|
+
# references via the supplied lookup strategies.
|
|
90
|
+
class ReferenceChecker
|
|
91
|
+
def initialize(registry, strategies)
|
|
92
|
+
@registry = registry
|
|
93
|
+
@strategies = strategies
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def check_all(database)
|
|
97
|
+
invalid = {}
|
|
98
|
+
check_unit_system_references(database, invalid)
|
|
99
|
+
check_quantity_references(database, invalid)
|
|
100
|
+
check_root_unit_references(database, invalid)
|
|
101
|
+
invalid
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def check_unit_system_references(database, invalid)
|
|
107
|
+
database.units.each_with_index do |unit, index|
|
|
108
|
+
refs = unit.unit_system_reference
|
|
109
|
+
next unless refs
|
|
110
|
+
|
|
111
|
+
refs.each_with_index do |ref, idx|
|
|
112
|
+
validate(ref, "unit_systems", "units",
|
|
113
|
+
"units:index:#{index}:unit_system_reference[#{idx}]", invalid)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def check_quantity_references(database, invalid)
|
|
119
|
+
database.units.each_with_index do |unit, index|
|
|
120
|
+
refs = unit.quantity_references
|
|
121
|
+
next unless refs
|
|
122
|
+
|
|
123
|
+
refs.each_with_index do |ref, idx|
|
|
124
|
+
validate(ref, "quantities", "units",
|
|
125
|
+
"units:index:#{index}:quantity_references[#{idx}]", invalid)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def check_root_unit_references(database, invalid)
|
|
131
|
+
database.units.each_with_index do |unit, index|
|
|
132
|
+
refs = unit.root_units
|
|
133
|
+
next unless refs
|
|
134
|
+
|
|
135
|
+
refs.each_with_index do |root_unit, idx|
|
|
136
|
+
if root_unit.unit_reference
|
|
137
|
+
validate(root_unit.unit_reference, "units", "units",
|
|
138
|
+
"units:index:#{index}:root_units.#{idx}.unit_reference",
|
|
139
|
+
invalid)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
next unless root_unit.prefix_reference
|
|
143
|
+
|
|
144
|
+
validate(root_unit.prefix_reference, "prefixes", "units",
|
|
145
|
+
"units:index:#{index}:root_units.#{idx}.prefix_reference",
|
|
146
|
+
invalid)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate(ref, ref_type, file_type, ref_path, invalid)
|
|
152
|
+
pair = Reference.destructure(ref)
|
|
153
|
+
valid = if pair
|
|
154
|
+
any_strategy_matches?(pair, ref_type)
|
|
155
|
+
else
|
|
156
|
+
@registry.key?(ref_type) && @registry[ref_type].key?(ref)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
return if valid
|
|
160
|
+
|
|
161
|
+
invalid[file_type] ||= {}
|
|
162
|
+
invalid[file_type][ref_path] = if pair
|
|
163
|
+
{ id: pair.id, type: pair.type, ref_type: ref_type }
|
|
164
|
+
else
|
|
165
|
+
{ id: ref, type: ref_type }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def any_strategy_matches?(pair, ref_type)
|
|
170
|
+
@strategies.any? { |s| s.call(pair, ref_type, @registry) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A normalized reference pair. `id` and `type` are strings.
|
|
175
|
+
ReferencePair = Struct.new(:id, :type, keyword_init: true)
|
|
176
|
+
|
|
177
|
+
# Coerces various reference shapes (Identifier instance, Hash with
|
|
178
|
+
# string keys, plain String) into a ReferencePair (or nil if the
|
|
179
|
+
# ref is a bare string with no type metadata).
|
|
180
|
+
module Reference
|
|
181
|
+
module_function
|
|
182
|
+
|
|
183
|
+
def destructure(ref)
|
|
184
|
+
case ref
|
|
185
|
+
when Unitsdb::Identifier
|
|
186
|
+
ReferencePair.new(id: ref.id, type: ref.type) if ref.id && ref.type
|
|
187
|
+
when Hash
|
|
188
|
+
ReferencePair.new(id: ref["id"], type: ref["type"]) if ref["id"] && ref["type"]
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Lookup strategies. Each is a proc that takes (pair, ref_type,
|
|
194
|
+
# registry) and returns true if the reference is valid under that
|
|
195
|
+
# strategy. Composed in ALL and tried in order by ReferenceChecker.
|
|
196
|
+
module LookupStrategies
|
|
197
|
+
# Exact "#{type}:#{id}" match.
|
|
198
|
+
COMPOSITE_KEY = ->(pair, ref_type, registry) do
|
|
199
|
+
registry.key?(ref_type) && registry[ref_type].key?("#{pair.type}:#{pair.id}")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Bare-id match (any type). For backward compatibility with
|
|
203
|
+
# databases that only stored the id without the type.
|
|
204
|
+
BARE_ID = ->(pair, ref_type, registry) do
|
|
205
|
+
registry.key?(ref_type) && registry[ref_type].key?(pair.id)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Unit-system alternate IDs. The UnitsML `unitsml` authority uses
|
|
209
|
+
# kebab-case (`si-base`) while NIST uses snake-case (`SI_base`).
|
|
210
|
+
# Accept either form when resolving unit_system references.
|
|
211
|
+
UNIT_SYSTEM_ALTERNATE = ->(pair, ref_type, registry) do
|
|
212
|
+
next false unless ref_type == "unit_systems" && pair.type == "unitsml"
|
|
213
|
+
next false unless registry.key?(ref_type)
|
|
214
|
+
|
|
215
|
+
alternates = []
|
|
216
|
+
alternates << pair.id
|
|
217
|
+
alternates << "SI_#{pair.id.sub('si-', '')}" if pair.id.start_with?("si-")
|
|
218
|
+
alternates << "non-SI_#{pair.id.sub('nonsi-', '')}" if pair.id.start_with?("nonsi-")
|
|
219
|
+
|
|
220
|
+
keys = registry[ref_type].keys
|
|
221
|
+
alternates.any? { |alt| keys.any? { |k| k.end_with?(":#{alt}") } }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
ALL = [COMPOSITE_KEY, BARE_ID, UNIT_SYSTEM_ALTERNATE].freeze
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unitsdb
|
|
4
|
+
class Database
|
|
5
|
+
# Validates that `short` names and identifier ids are unique
|
|
6
|
+
# within each collection. Encodes the policy of which
|
|
7
|
+
# collections participate in each check via two constants so
|
|
8
|
+
# the intent is explicit.
|
|
9
|
+
#
|
|
10
|
+
# UniquenessValidator.new(db).validate
|
|
11
|
+
# # => #<struct short={...}, id={...}>
|
|
12
|
+
class UniquenessValidator
|
|
13
|
+
# Collections that carry a `short` attribute and must be
|
|
14
|
+
# unique-by-short within each collection.
|
|
15
|
+
SHORT_COLLECTIONS = %i[units dimensions unit_systems].freeze
|
|
16
|
+
|
|
17
|
+
# Collections whose identifier `id` values must be unique.
|
|
18
|
+
IDENTIFIER_COLLECTIONS = Database::COLLECTIONS
|
|
19
|
+
|
|
20
|
+
Result = Struct.new(:short, :id, keyword_init: true) do
|
|
21
|
+
def empty?
|
|
22
|
+
short.empty? && id.empty?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(database)
|
|
27
|
+
@database = database
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate
|
|
31
|
+
Result.new(
|
|
32
|
+
short: scan_shorts,
|
|
33
|
+
id: scan_identifiers,
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Convenience entry-point used by Database#validate_uniqueness.
|
|
38
|
+
def self.validate(database)
|
|
39
|
+
result = new(database).validate
|
|
40
|
+
{
|
|
41
|
+
short: result.short,
|
|
42
|
+
id: result.id,
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def scan_shorts
|
|
49
|
+
SHORT_COLLECTIONS.each_with_object({}) do |name, results|
|
|
50
|
+
by_value = {}
|
|
51
|
+
@database.collection(name).each_with_index do |entity, index|
|
|
52
|
+
next unless entity.short
|
|
53
|
+
|
|
54
|
+
(by_value[entity.short] ||= []) << "index:#{index}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
dups = by_value.reject { |_, paths| paths.size == 1 }
|
|
58
|
+
results[name.to_s] = dups unless dups.empty?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def scan_identifiers
|
|
63
|
+
IDENTIFIER_COLLECTIONS.each_with_object({}) do |name, results|
|
|
64
|
+
by_id = {}
|
|
65
|
+
@database.collection(name).each_with_index do |entity, index|
|
|
66
|
+
entity.identifiers.each_with_index do |identifier, id_index|
|
|
67
|
+
next unless identifier.id
|
|
68
|
+
|
|
69
|
+
loc = "index:#{index}:identifiers[#{id_index}]"
|
|
70
|
+
(by_id[identifier.id] ||= []) << loc
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
dups = by_id.transform_values(&:uniq).reject { |_, paths| paths.size == 1 }
|
|
75
|
+
results[name.to_s] = dups unless dups.empty?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|