unitsdb 2.2.2 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/opal.yml +36 -0
  3. data/.gitignore +3 -0
  4. data/Gemfile +2 -1
  5. data/Rakefile +3 -1
  6. data/lib/unitsdb/cli.rb +5 -41
  7. data/lib/unitsdb/commands/_modify.rb +1 -34
  8. data/lib/unitsdb/commands/check_si/si_formatter.rb +6 -6
  9. data/lib/unitsdb/commands/check_si/si_matcher.rb +202 -292
  10. data/lib/unitsdb/commands/check_si/si_updater.rb +16 -36
  11. data/lib/unitsdb/commands/entity_presenter.rb +98 -0
  12. data/lib/unitsdb/commands/get.rb +16 -113
  13. data/lib/unitsdb/commands/qudt/formatter.rb +16 -27
  14. data/lib/unitsdb/commands/qudt/matcher.rb +18 -28
  15. data/lib/unitsdb/commands/qudt/updater.rb +8 -11
  16. data/lib/unitsdb/commands/qudt.rb +1 -34
  17. data/lib/unitsdb/commands/search.rb +33 -188
  18. data/lib/unitsdb/commands/thor.rb +41 -0
  19. data/lib/unitsdb/commands/ucum/formatter.rb +9 -18
  20. data/lib/unitsdb/commands/ucum/matcher.rb +4 -4
  21. data/lib/unitsdb/commands/ucum/updater.rb +3 -5
  22. data/lib/unitsdb/commands/ucum.rb +1 -34
  23. data/lib/unitsdb/commands/validate/qudt_references.rb +29 -70
  24. data/lib/unitsdb/commands/validate/references.rb +5 -303
  25. data/lib/unitsdb/commands/validate/si_references.rb +30 -66
  26. data/lib/unitsdb/commands/validate/ucum_references.rb +30 -64
  27. data/lib/unitsdb/commands/validate.rb +1 -36
  28. data/lib/unitsdb/commands.rb +2 -0
  29. data/lib/unitsdb/config.rb +83 -29
  30. data/lib/unitsdb/database/loader.rb +135 -0
  31. data/lib/unitsdb/database/reference_validator.rb +227 -0
  32. data/lib/unitsdb/database/uniqueness_validator.rb +80 -0
  33. data/lib/unitsdb/database.rb +124 -584
  34. data/lib/unitsdb/dimension.rb +0 -27
  35. data/lib/unitsdb/dimensions.rb +0 -2
  36. data/lib/unitsdb/opal.rb +43 -0
  37. data/lib/unitsdb/prefix.rb +0 -13
  38. data/lib/unitsdb/prefixes.rb +0 -2
  39. data/lib/unitsdb/quantities.rb +0 -1
  40. data/lib/unitsdb/quantity.rb +0 -2
  41. data/lib/unitsdb/quantity_reference.rb +0 -2
  42. data/lib/unitsdb/root_unit_reference.rb +0 -2
  43. data/lib/unitsdb/scale.rb +0 -2
  44. data/lib/unitsdb/scale_properties.rb +0 -1
  45. data/lib/unitsdb/scales.rb +0 -1
  46. data/lib/unitsdb/si_derived_base.rb +0 -1
  47. data/lib/unitsdb/symbol_presentations.rb +0 -2
  48. data/lib/unitsdb/unit.rb +0 -34
  49. data/lib/unitsdb/unit_system.rb +0 -2
  50. data/lib/unitsdb/unit_systems.rb +0 -2
  51. data/lib/unitsdb/units.rb +0 -2
  52. data/lib/unitsdb/version.rb +1 -1
  53. data/lib/unitsdb.rb +142 -35
  54. data/unitsdb.gemspec +1 -0
  55. metadata +23 -2
@@ -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
- def models
22
- @models ||= {}
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
- normalized_models = user_models.each_with_object({}) do |(id, klass), result|
27
- model_id = id.to_sym
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
- model_id = model_name.to_sym
36
- models[model_id] || registered_models[model_id]
41
+ registered_models[model_name.to_sym]
37
42
  end
38
43
 
39
- def register(id = context_id)
40
- explicit_registers[id.to_sym]
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
- def context(id = context_id, force_populate: false)
69
- existing = find_context(id)
70
- return existing if existing && !force_populate && populated?(id)
71
-
72
- populate_context(id: id)
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
- def populate_context(id: context_id, fallback_to: [:default], substitutions: [])
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
- context = Lutaml::Model::GlobalContext.create_context(
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
- def populated?(context_id)
118
- @populated_for&.[](context_id.to_sym)
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 mark_populated!(context_id)
122
- @populated_for ||= {}
123
- @populated_for[context_id.to_sym] = true
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
- def explicit_registers
127
- @explicit_registers ||= {}
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