opencdd 0.1.1

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 (97) hide show
  1. checksums.yaml +7 -0
  2. data/CLAUDE.md +486 -0
  3. data/README.md +304 -0
  4. data/bin/lint-no-raw-mdc +41 -0
  5. data/lib/cdd.rb +11 -0
  6. data/lib/opencdd/alias_table.rb +52 -0
  7. data/lib/opencdd/cddal/ast.rb +151 -0
  8. data/lib/opencdd/cddal/builder.rb +374 -0
  9. data/lib/opencdd/cddal/fetcher/in_memory.rb +32 -0
  10. data/lib/opencdd/cddal/fetcher/net_http.rb +84 -0
  11. data/lib/opencdd/cddal/fetcher.rb +18 -0
  12. data/lib/opencdd/cddal/generated_parser.rb +805 -0
  13. data/lib/opencdd/cddal/lexer.rb +193 -0
  14. data/lib/opencdd/cddal/parser.rb +19 -0
  15. data/lib/opencdd/cddal/resolver.rb +100 -0
  16. data/lib/opencdd/cddal/serializer.rb +210 -0
  17. data/lib/opencdd/cddal/value_serializer.rb +60 -0
  18. data/lib/opencdd/cddal.rb +63 -0
  19. data/lib/opencdd/class_tree.rb +80 -0
  20. data/lib/opencdd/class_type.rb +33 -0
  21. data/lib/opencdd/codegen/ts.rb +185 -0
  22. data/lib/opencdd/codegen.rb +7 -0
  23. data/lib/opencdd/composition_tree.rb +119 -0
  24. data/lib/opencdd/condition.rb +120 -0
  25. data/lib/opencdd/data_type.rb +143 -0
  26. data/lib/opencdd/database.rb +719 -0
  27. data/lib/opencdd/effective_properties.rb +119 -0
  28. data/lib/opencdd/entity/field_reader.rb +141 -0
  29. data/lib/opencdd/entity/field_registry.rb +99 -0
  30. data/lib/opencdd/entity/version_history.rb +62 -0
  31. data/lib/opencdd/entity.rb +255 -0
  32. data/lib/opencdd/exporters/json.rb +235 -0
  33. data/lib/opencdd/exporters/mermaid.rb +62 -0
  34. data/lib/opencdd/exporters/yaml.rb +16 -0
  35. data/lib/opencdd/exporters.rb +9 -0
  36. data/lib/opencdd/guid.rb +27 -0
  37. data/lib/opencdd/instance_rule.rb +70 -0
  38. data/lib/opencdd/irdi.rb +128 -0
  39. data/lib/opencdd/klass.rb +230 -0
  40. data/lib/opencdd/languages.rb +70 -0
  41. data/lib/opencdd/meta_class.rb +274 -0
  42. data/lib/opencdd/model/entity_store.rb +85 -0
  43. data/lib/opencdd/model/yaml_database.rb +49 -0
  44. data/lib/opencdd/model/yaml_entity.rb +196 -0
  45. data/lib/opencdd/model.rb +13 -0
  46. data/lib/opencdd/parcel/csv_reader.rb +35 -0
  47. data/lib/opencdd/parcel/csv_writer.rb +114 -0
  48. data/lib/opencdd/parcel/entity_manifest.rb +119 -0
  49. data/lib/opencdd/parcel/flat_dir_reader.rb +134 -0
  50. data/lib/opencdd/parcel/layout_detector.rb +115 -0
  51. data/lib/opencdd/parcel/metadata.rb +112 -0
  52. data/lib/opencdd/parcel/referenced_irdis.rb +91 -0
  53. data/lib/opencdd/parcel/scrape_verifier.rb +122 -0
  54. data/lib/opencdd/parcel/selector.rb +115 -0
  55. data/lib/opencdd/parcel/sharded_dir_reader.rb +151 -0
  56. data/lib/opencdd/parcel/sheet.rb +287 -0
  57. data/lib/opencdd/parcel/sheet_emitter.rb +171 -0
  58. data/lib/opencdd/parcel/sheet_schema.rb +253 -0
  59. data/lib/opencdd/parcel/workbook.rb +172 -0
  60. data/lib/opencdd/parcel/workbook_reader.rb +200 -0
  61. data/lib/opencdd/parcel/writer.rb +185 -0
  62. data/lib/opencdd/parcel.rb +175 -0
  63. data/lib/opencdd/parse_helpers.rb +73 -0
  64. data/lib/opencdd/property.rb +120 -0
  65. data/lib/opencdd/property_data_element_type.rb +44 -0
  66. data/lib/opencdd/property_ids.rb +202 -0
  67. data/lib/opencdd/reader.rb +103 -0
  68. data/lib/opencdd/relation.rb +88 -0
  69. data/lib/opencdd/relation_tree.rb +74 -0
  70. data/lib/opencdd/relation_type.rb +58 -0
  71. data/lib/opencdd/structured_values.rb +183 -0
  72. data/lib/opencdd/unit.rb +16 -0
  73. data/lib/opencdd/validator/class_reference_rule.rb +77 -0
  74. data/lib/opencdd/validator/condition_rule.rb +27 -0
  75. data/lib/opencdd/validator/data_type_rule.rb +26 -0
  76. data/lib/opencdd/validator/enum_rule.rb +27 -0
  77. data/lib/opencdd/validator/format_rule.rb +54 -0
  78. data/lib/opencdd/validator/hierarchy_rule.rb +65 -0
  79. data/lib/opencdd/validator/irdi_rule.rb +57 -0
  80. data/lib/opencdd/validator/mandatory_rule.rb +25 -0
  81. data/lib/opencdd/validator/pattern_rule.rb +26 -0
  82. data/lib/opencdd/validator/reference_rule.rb +36 -0
  83. data/lib/opencdd/validator/rule.rb +65 -0
  84. data/lib/opencdd/validator/runner.rb +101 -0
  85. data/lib/opencdd/validator/set_rule.rb +41 -0
  86. data/lib/opencdd/validator/synonym_rule.rb +30 -0
  87. data/lib/opencdd/validator/type_rule.rb +102 -0
  88. data/lib/opencdd/validator/uniqueness_rule.rb +32 -0
  89. data/lib/opencdd/validator.rb +68 -0
  90. data/lib/opencdd/value_format.rb +71 -0
  91. data/lib/opencdd/value_list.rb +42 -0
  92. data/lib/opencdd/value_term.rb +24 -0
  93. data/lib/opencdd/version.rb +5 -0
  94. data/lib/opencdd/view_control.rb +10 -0
  95. data/lib/opencdd/visitor.rb +93 -0
  96. data/lib/opencdd.rb +57 -0
  97. metadata +325 -0
@@ -0,0 +1,719 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ class Database
5
+ Dictionary = Struct.new(
6
+ :parcel_id, :source_language, :translation_languages, :meta_class_irdis,
7
+ keyword_init: true,
8
+ )
9
+
10
+ FORCED_META_CLASSES = %w[MDC_C002 MDC_C003].freeze
11
+ PARCEL_ID_PATTERN = /\A[A-Z0-9-]+\z/.freeze
12
+
13
+ attr_reader :workbooks, :unresolved_refs, :alias_table
14
+
15
+ def self.load(path)
16
+ Opencdd::Reader.load_database(path)
17
+ end
18
+
19
+ def self.load_workbook(path)
20
+ Opencdd::Parcel::WorkbookReader.new(path).load_into(new)
21
+ end
22
+
23
+ def self.load_flat_dir(path)
24
+ Opencdd::Parcel::FlatDirReader.new(path).load_into(new)
25
+ end
26
+
27
+ def self.load_sharded_dir(path)
28
+ Opencdd::Parcel::ShardedDirReader.new(path).load_into(new)
29
+ end
30
+
31
+ def initialize
32
+ @entities_by_irdi = {}
33
+ @entities_by_code = Hash.new { |h, k| h[k] = [] }
34
+ @entities_by_type = Hash.new { |h, k| h[k] = [] }
35
+ @workbooks = []
36
+ @unresolved_refs = []
37
+ @class_by_property_irdi = nil
38
+ @symbol_table = {}
39
+ @alias_table = Opencdd::AliasTable.new(defaults: true)
40
+ @entity_sources = {}
41
+ end
42
+
43
+ def add_workbook(workbook)
44
+ @workbooks << workbook
45
+ parcel_id = workbook.parcel_id
46
+ workbook.each_sheet do |sheet|
47
+ next unless sheet.type && sheet.rows.any?
48
+ type = sheet.type
49
+ entity_class = Opencdd::MetaClasses.entity_class_for_type(type)
50
+ raise "Unknown entity type #{type.inspect} for sheet #{sheet.name.inspect}" unless entity_class
51
+
52
+ sheet.rows.each do |row|
53
+ entity = entity_class.from_row(
54
+ row,
55
+ schema: sheet.schema,
56
+ meta_class_irdi: sheet.meta_class_irdi,
57
+ )
58
+ add_entity(entity)
59
+ if parcel_id && entity.irdi
60
+ prev = @entity_sources[entity.irdi]
61
+ @entity_sources[entity.irdi] = prev.nil? ? parcel_id : prev
62
+ end
63
+ end
64
+ end
65
+ self
66
+ end
67
+
68
+ def add_entity(entity)
69
+ irdi = entity.irdi
70
+ if irdi.nil?
71
+ @unresolved_refs << [nil, entity]
72
+ return self
73
+ end
74
+
75
+ if @entities_by_irdi.key?(irdi)
76
+ existing = @entities_by_irdi[irdi]
77
+ return self if existing.properties == entity.properties
78
+ warn "Duplicate IRDI #{irdi} — overwriting"
79
+ end
80
+
81
+ @entities_by_irdi[irdi] = entity
82
+ @entities_by_code[irdi.code] << entity if irdi.code
83
+ @entities_by_type[entity.type] << entity if entity.type
84
+
85
+ register_entity_symbols(entity)
86
+
87
+ if entity.is_a?(Opencdd::Klass)
88
+ entity.attach_database(self)
89
+ end
90
+ self
91
+ end
92
+
93
+ def register_symbol(name, entity)
94
+ bind_symbol(name, entity)
95
+ end
96
+
97
+ def register_entity_symbols(entity)
98
+ name = entity.is_a?(Opencdd::Entity) ? entity.preferred_name : nil
99
+ bind_symbol(name, entity) if name
100
+ code = entity.code
101
+ bind_symbol(code, entity) if code
102
+ self
103
+ end
104
+
105
+ def resolve_reference(ref)
106
+ return nil if ref.nil?
107
+ return ref if ref.is_a?(Opencdd::Entity)
108
+
109
+ key = ref.to_s.strip
110
+ return nil if key.empty?
111
+
112
+ if key.include?("/") || key.include?("#")
113
+ irdi = Opencdd::IRDI.parse(key)
114
+ return find(irdi)
115
+ end
116
+
117
+ entity = @symbol_table[key]
118
+ return entity if entity
119
+
120
+ by_code = find_by_code(key)
121
+ return by_code if by_code
122
+
123
+ irdi = Opencdd::IRDI.parse(key)
124
+ irdi ? find(irdi) : nil
125
+ end
126
+
127
+ def finalize!
128
+ normalize_reference_collections!
129
+ link_class_hierarchy!
130
+ link_property_classes!
131
+ link_value_lists!
132
+ rebuild_symbol_table!
133
+ self
134
+ end
135
+
136
+ def normalize_reference_collections!
137
+ each_reference_collection do |entity, pid, _raw, elements|
138
+ entity.properties[pid] = Opencdd::StructuredValues.rejoin(elements)
139
+ end
140
+ self
141
+ end
142
+
143
+ # Single iteration shape for any method that walks every
144
+ # set_of_refs property across every entity. Yields
145
+ # (entity, property_id, raw_value, elements) where +elements+
146
+ # is the unwrapped Array<String> via StructuredValues.
147
+ # Returns an Enumerator when no block is given.
148
+ def each_reference_collection
149
+ return enum_for(:each_reference_collection) unless block_given?
150
+ set_property_ids = Opencdd::PropertyIds::REGISTRY
151
+ .select { |_, e| e.value_kind == :set_of_refs }
152
+ .keys
153
+ entities.each do |entity|
154
+ set_property_ids.each do |pid|
155
+ raw = entity.properties[pid]
156
+ next if raw.nil? || raw.to_s.strip.empty?
157
+ elements = Opencdd::StructuredValues.unwrap_and_split(raw)
158
+ next if elements.empty?
159
+ yield(entity, pid, raw, elements)
160
+ end
161
+ end
162
+ end
163
+
164
+ def find(irdi)
165
+ return nil if irdi.nil?
166
+ key = Opencdd::IRDI === irdi ? irdi : Opencdd::IRDI.parse(irdi)
167
+ return nil unless key
168
+ @entities_by_irdi[key]
169
+ end
170
+
171
+ # Coerce +value+ (Entity, IRDI, String, or nil) to an Entity.
172
+ # Returns nil if the value cannot be resolved. Single entry
173
+ # point for the "entity or irdi" pattern that used to be
174
+ # inlined at every call site.
175
+ def coerce_entity(value)
176
+ return nil if value.nil?
177
+ return value if value.is_a?(Opencdd::Entity)
178
+ find(value)
179
+ end
180
+
181
+ # Coerce +value+ to an IRDI. Accepts an Entity (returns its
182
+ # irdi), an IRDI (pass-through), or a String (parsed). Returns
183
+ # nil if the value cannot be parsed.
184
+ def coerce_irdi(value)
185
+ return nil if value.nil?
186
+ return value.irdi if value.is_a?(Opencdd::Entity)
187
+ return value if value.is_a?(Opencdd::IRDI)
188
+ Opencdd::IRDI.parse(value.to_s)
189
+ end
190
+
191
+ def find_by_code(code)
192
+ matches = @entities_by_code[code.to_s]
193
+ return nil if matches.empty?
194
+ matches.first
195
+ end
196
+
197
+ def find_all_by_code(code)
198
+ @entities_by_code[code.to_s].dup
199
+ end
200
+
201
+ def find_by_name(name, type: nil, lang: :en)
202
+ pools = type ? [@entities_by_type[type]].compact : @entities_by_type.values
203
+ pools.each do |pool|
204
+ hit = pool.find { |e| e.preferred_name(lang).to_s.casecmp(name.to_s).zero? }
205
+ return hit if hit
206
+ end
207
+ nil
208
+ end
209
+
210
+ def classes ; @entities_by_type[:class] || [] ; end
211
+ def properties ; @entities_by_type[:property] || [] ; end
212
+ def units ; @entities_by_type[:unit] || [] ; end
213
+ def value_lists ; @entities_by_type[:value_list] || [] ; end
214
+ def value_terms ; @entities_by_type[:value_term] || [] ; end
215
+ def relations ; @entities_by_type[:relation] || [] ; end
216
+ def view_controls ; @entities_by_type[:view_control] || [] ; end
217
+
218
+ def entities_of_type(type)
219
+ @entities_by_type[type.to_sym] || []
220
+ end
221
+
222
+ def entities
223
+ @entities_by_irdi.values
224
+ end
225
+
226
+ def count(type = nil)
227
+ type ? (@entities_by_type[type] || []).size : @entities_by_irdi.size
228
+ end
229
+
230
+ def root_classes
231
+ classes.select { |c| c.parent_irdi.nil? }
232
+ end
233
+
234
+ # ── Powertype accessors (4-layer ontology) ──────────────────
235
+ #
236
+ # Powertype semantics: a CATEGORICAL_CLASS at M1 has subclasses
237
+ # that are themselves classes but also act as its instances
238
+ # (used in CLASS_REFERENCE data types and sub_class_selection).
239
+ # See Opencdd module docstring + Klass#powertype?.
240
+
241
+ # All categorical classes registered in this database. Each is
242
+ # a candidate target for a CLASS_REFERENCE data type.
243
+ def categorical_classes
244
+ classes.select(&:powertype?)
245
+ end
246
+
247
+ # Powertype instances of +categorical_klass+ — its valid options
248
+ # for CLASS_REFERENCE values. Accepts a Klass, IRDI, or code
249
+ # String. Returns [] if +categorical_klass+ is not a powertype.
250
+ def instances_of(categorical_klass)
251
+ k = coerce_entity(categorical_klass)
252
+ return [] unless k.is_a?(Opencdd::Klass)
253
+ k.categorical_instances(self)
254
+ end
255
+
256
+ # Predicate: is +value+ a valid powertype instance of the given
257
+ # categorical class? Used by the validator's CLASS_REFERENCE
258
+ # check (R04/R08 extension).
259
+ def valid_class_reference?(categorical_klass, value)
260
+ target = coerce_entity(value)
261
+ return false unless target
262
+ instances_of(categorical_klass).any? { |c| c.irdi == target.irdi }
263
+ end
264
+
265
+ def class_tree(fields: Opencdd::ClassTree::DEFAULT_FIELDS)
266
+ Opencdd::ClassTree.new(self, fields: fields)
267
+ end
268
+
269
+ def effective_properties
270
+ @effective_properties ||= Opencdd::EffectiveProperties.new(self)
271
+ end
272
+
273
+ def composition_tree(klass, max_depth: 10)
274
+ Opencdd::CompositionTree.new(self).for(klass, max_depth: max_depth)
275
+ end
276
+
277
+ def relation_tree(root = nil, max_depth: 10)
278
+ Opencdd::RelationTree.new(self).for(root, max_depth: max_depth)
279
+ end
280
+
281
+ def add_dictionary(dict)
282
+ validate_parcel_id!(dict.parcel_id)
283
+ source_language = dict.source_language || "en"
284
+ translation_languages = Array(dict.translation_languages)
285
+ meta_irdis = normalize_meta_class_irdis(dict.meta_class_irdis)
286
+
287
+ sheets = meta_irdis.map do |meta_irdi|
288
+ Opencdd::Parcel::Sheet.scaffold(
289
+ meta_class_irdi: meta_irdi,
290
+ parcel_id: dict.parcel_id,
291
+ source_language: source_language,
292
+ translation_languages: translation_languages,
293
+ )
294
+ end
295
+
296
+ project = Opencdd::Parcel::Workbook::ProjectInfo.new(
297
+ project_id: dict.parcel_id,
298
+ parcel_id: dict.parcel_id,
299
+ multi_language: translation_languages.join(","),
300
+ base_language: source_language,
301
+ )
302
+ sheetmap = build_sheetmap_for(dict.parcel_id, sheets)
303
+ workbook = Opencdd::Parcel::Workbook.new(
304
+ sheets: sheets, sheetmap: sheetmap, project: project,
305
+ )
306
+ @workbooks << workbook
307
+ workbook
308
+ end
309
+
310
+ def drop_dictionary(parcel_id)
311
+ matching = @workbooks.select { |wb| wb.parcel_id == parcel_id }
312
+ raise ArgumentError, "no dictionary with parcel_id #{parcel_id.inspect}" if matching.empty?
313
+
314
+ irdis_to_drop = @entity_sources.select { |_, pid| pid == parcel_id }.keys
315
+ irdis_to_drop.each do |irdi|
316
+ remove_entity_by_irdi!(irdi)
317
+ @entity_sources.delete(irdi)
318
+ end
319
+ @workbooks.reject! { |wb| wb.parcel_id == parcel_id }
320
+ self
321
+ end
322
+
323
+ def register_external_sheet(sheet, parcel_id:)
324
+ validate_parcel_id!(parcel_id)
325
+ wb = @workbooks.find { |w| w.parcel_id == parcel_id }
326
+ if wb.nil?
327
+ project = Opencdd::Parcel::Workbook::ProjectInfo.new(
328
+ project_id: parcel_id, parcel_id: parcel_id,
329
+ multi_language: "", base_language: "en",
330
+ )
331
+ wb = Opencdd::Parcel::Workbook.new(sheets: [], sheetmap: [], project: project)
332
+ @workbooks << wb
333
+ end
334
+ wb.register_sheet(sheet)
335
+ self
336
+ end
337
+
338
+ def dictionaries
339
+ @workbooks.map do |wb|
340
+ Dictionary.new(
341
+ parcel_id: wb.parcel_id,
342
+ source_language: wb.base_language,
343
+ translation_languages: parse_translation_languages(wb),
344
+ meta_class_irdis: wb.sheets.map(&:meta_class_irdi).compact.uniq,
345
+ )
346
+ end
347
+ end
348
+
349
+ def rename_entity(old_code, new_code)
350
+ old_code_s = old_code.to_s
351
+ new_code_s = new_code.to_s
352
+ return self if old_code_s == new_code_s
353
+
354
+ target = find_by_code(old_code_s)
355
+ return self unless target
356
+
357
+ clash = find_by_code(new_code_s)
358
+ if clash && clash.irdi != target.irdi
359
+ warn "Cannot rename #{old_code_s} → #{new_code_s}: target code already in use by #{clash.irdi}"
360
+ return self
361
+ end
362
+
363
+ old_irdi = target.irdi
364
+ new_irdi = old_irdi.with_code(new_code_s)
365
+
366
+ @entities_by_irdi.delete(old_irdi)
367
+ @entities_by_code[old_code_s].delete(target)
368
+
369
+ ref_property_ids = Opencdd::PropertyIds::REGISTRY.select do |_, e|
370
+ REFERENCE_VALUE_KINDS.include?(e.value_kind)
371
+ end.keys
372
+
373
+ entities.each do |entity|
374
+ rewrite_back_references!(entity, old_irdi, new_irdi, ref_property_ids)
375
+ end
376
+
377
+ code_pid = target.class.default_code_property_id(target.meta_class_irdi)
378
+ target.replace_code_value!(code_pid, new_code_s)
379
+ target.replace_irdi!(new_irdi)
380
+
381
+ @entities_by_irdi[new_irdi] = target
382
+ @entities_by_code[new_code_s] << target
383
+
384
+ rebuild_symbol_table!
385
+ self
386
+ end
387
+
388
+ def apply_view_control(klass, view_control)
389
+ properties = effective_properties.for(klass).to_a
390
+ return properties unless view_control.is_a?(Opencdd::ViewControl)
391
+
392
+ controlled = view_control.controlled_class_irdis
393
+ k = coerce_entity(klass)
394
+ return properties unless k.is_a?(Opencdd::Klass) && controlled.include?(k.irdi)
395
+
396
+ shown = view_control.shown_property_irdis
397
+ lookup = properties.each_with_object({}) { |p, h| h[p.irdi] = p }
398
+ shown.filter_map { |irdi| lookup[irdi] }
399
+ end
400
+
401
+ def properties_of(klass)
402
+ k = coerce_entity(klass)
403
+ return [] unless k.is_a?(Opencdd::Klass)
404
+ k.properties_on_class(self)
405
+ end
406
+
407
+ def classes_with_property(property)
408
+ irdi = coerce_irdi(property)
409
+ return [] unless irdi
410
+ @class_by_property_irdi&.fetch(irdi, []) || []
411
+ end
412
+
413
+ def value_list_of(property)
414
+ prop = coerce_entity(property)
415
+ return nil unless prop.is_a?(Opencdd::Property)
416
+
417
+ relations.each do |r|
418
+ next unless r.predication?
419
+ next unless r.domain_irdis.include?(prop.irdi)
420
+ vl = r.codomain_irdi && find(r.codomain_irdi)
421
+ return vl if vl.is_a?(Opencdd::ValueList)
422
+ end
423
+
424
+ return nil unless prop.enum?
425
+ identifier = value_list_identifier_of(prop)
426
+ return nil unless identifier
427
+ vl = resolve_reference(identifier)
428
+ vl if vl.is_a?(Opencdd::ValueList)
429
+ end
430
+
431
+ def value_list_identifier_of(property)
432
+ case property.parsed_data_type
433
+ when Opencdd::DataType::EnumStringType, Opencdd::DataType::EnumReferenceType
434
+ property.parsed_data_type.value_list_identifier
435
+ end
436
+ end
437
+
438
+ def terms_of(value_list)
439
+ return [] if value_list.nil?
440
+ value_list.term_irdis.map { |i| find(i) }.compact
441
+ end
442
+
443
+ def relations_for(domain: nil, codomain: nil)
444
+ dom = coerce_irdi(domain)
445
+ cod = coerce_irdi(codomain)
446
+ relations.select do |r|
447
+ (dom.nil? || r.domain_irdis.include?(dom)) &&
448
+ (cod.nil? || r.codomain_irdi == cod)
449
+ end
450
+ end
451
+
452
+ def functions_involving(property)
453
+ irdi = coerce_irdi(property)
454
+ return [] unless irdi
455
+ relations.select do |r|
456
+ r.function? && (r.domain_irdis.include?(irdi) || r.codomain_irdi == irdi)
457
+ end
458
+ end
459
+
460
+ def merge(other)
461
+ raise TypeError, "merge expects a Opencdd::Database" unless other.is_a?(Opencdd::Database)
462
+ other.entities.each { |e| add_entity(e) }
463
+ finalize!
464
+ self
465
+ end
466
+
467
+ # Applies a Parcel change request (CR) to this database.
468
+ #
469
+ # +cr+ is a +Opencdd::Database+ whose entities represent the
470
+ # additions and updates to apply. +removals:+ is an Array of
471
+ # IRDIs (Strings or +Opencdd::IRDI+ instances) identifying entities
472
+ # to delete from this database.
473
+ #
474
+ # Unlike +#merge+, which is purely additive, +#apply_change_request+
475
+ # honors an explicit removal list. The CR's own entities are
476
+ # upserted first (so an update-then-remove ordering resolves to
477
+ # remove), then removals are applied.
478
+ #
479
+ # Returns +self+ for chaining.
480
+ def apply_change_request(cr, removals: [])
481
+ raise TypeError, "apply_change_request expects a Opencdd::Database" unless cr.is_a?(Opencdd::Database)
482
+ cr.entities.each { |e| add_entity(e) }
483
+ Array(removals).each { |r| remove_by_irdi(r) }
484
+ finalize!
485
+ self
486
+ end
487
+
488
+ def remove_by_irdi(ref)
489
+ irdi = ref.is_a?(Opencdd::IRDI) ? ref : Opencdd::IRDI.parse(ref.to_s)
490
+ return unless irdi
491
+ remove_entity_by_irdi!(irdi)
492
+ @entity_sources.delete(irdi)
493
+ self
494
+ end
495
+
496
+ def each(&block)
497
+ @entities_by_irdi.each_value(&block)
498
+ end
499
+
500
+ include Enumerable
501
+
502
+ # ── YAML persistence (lutaml-model, CDD-native) ──────────
503
+ # Canonical YAML shape using semantic CDD attribute names
504
+ # (preferred_name, superclass, class_type) — not wire-format
505
+ # keys (MDC_P004, MDC_P010). See TODO.impl/33.
506
+
507
+ def to_yaml(*args)
508
+ Opencdd::Model::YamlDatabase.from_database(self).to_yaml(*args)
509
+ end
510
+
511
+ def self.from_yaml(yaml)
512
+ ydb = Opencdd::Model::YamlDatabase.from_yaml(yaml)
513
+ ydb.to_database
514
+ end
515
+
516
+ # ── Per-entity YAML persistence (lutaml-store) ───────────
517
+ # One .yaml file per entity in a directory layout. Diff-friendly
518
+ # at the entity level — one git diff shows exactly what changed.
519
+ # See TODO.impl/34.
520
+
521
+ def save_to_directory(path)
522
+ Opencdd::Model::EntityStore.new(path).save_database(self)
523
+ self
524
+ end
525
+
526
+ def self.load_from_directory(path)
527
+ Opencdd::Model::EntityStore.new(path).load_database
528
+ end
529
+
530
+ def semantically_equal?(other)
531
+ return false unless other.is_a?(Opencdd::Database)
532
+ return false unless entities.size == other.entities.size
533
+ entities.all? do |e|
534
+ oe = other.find(e.irdi)
535
+ oe && e.type == oe.type && e.properties == oe.properties
536
+ end
537
+ end
538
+
539
+ def to_s
540
+ "#<#{self.class.name} classes=#{classes.size} properties=#{properties.size} " \
541
+ "units=#{units.size} value_lists=#{value_lists.size} value_terms=#{value_terms.size} " \
542
+ "relations=#{relations.size}>"
543
+ end
544
+
545
+ alias_method :inspect, :to_s
546
+
547
+ private
548
+
549
+ REFERENCE_VALUE_KINDS = %i[identifier_ref set_of_refs class_ref].freeze
550
+
551
+ def rewrite_back_references!(entity, old_irdi, new_irdi, ref_property_ids)
552
+ ref_property_ids.each do |pid|
553
+ raw = entity.properties[pid]
554
+ next if raw.nil?
555
+ entry = Opencdd::PropertyIds::REGISTRY[pid]
556
+ case entry.value_kind
557
+ when :identifier_ref
558
+ parsed = Opencdd::IRDI.parse(raw.to_s)
559
+ next unless parsed == old_irdi
560
+ entity.properties[pid] = new_irdi.to_s
561
+ when :set_of_refs
562
+ # SSOT: split + rejoin via StructuredValues.
563
+ elements = Opencdd::StructuredValues.unwrap_and_split(raw)
564
+ next if elements.empty?
565
+ mapped = elements.map { |e| e == old_irdi.to_s ? new_irdi.to_s : e }
566
+ entity.properties[pid] = Opencdd::StructuredValues.rejoin(mapped)
567
+ when :class_ref
568
+ entity.properties[pid] = substitute_class_ref_value(raw.to_s, old_irdi, new_irdi)
569
+ end
570
+ end
571
+ end
572
+
573
+ def substitute_class_ref_value(raw, old_irdi, new_irdi)
574
+ out = raw.sub(old_irdi.to_s, new_irdi.to_s)
575
+ return out if old_irdi.code.nil? || old_irdi.code == new_irdi.code
576
+ out.sub(old_irdi.code, new_irdi.code)
577
+ end
578
+
579
+ def bind_symbol(name, entity)
580
+ key = name.to_s
581
+ existing = @symbol_table[key]
582
+ if existing && existing.irdi != entity.irdi
583
+ warn "Symbol #{key.inspect} already bound to #{existing.irdi}; ignoring rebind to #{entity.irdi}"
584
+ return self
585
+ end
586
+ @symbol_table[key] = entity
587
+ self
588
+ end
589
+ def rebuild_symbol_table!
590
+ entities.each { |e| register_entity_symbols(e) }
591
+ end
592
+
593
+ def link_class_hierarchy!
594
+ classes.each do |klass|
595
+ next if klass.parent_irdi
596
+ parent_id = klass.parent_property_id || Opencdd::PropertyIds::MDC_P010
597
+ parent_raw = klass.properties[parent_id]
598
+ next if parent_raw.nil? || parent_raw.to_s.strip.empty?
599
+ target = resolve_reference(parent_raw)
600
+ next unless target
601
+ klass.attach_parent_irdi(target.irdi)
602
+ target.add_child(klass)
603
+ end
604
+ end
605
+
606
+ def link_property_classes!
607
+ @class_by_property_irdi = Hash.new { |h, k| h[k] = [] }
608
+ link_property_classes_via_relations!
609
+ link_property_classes_via_definition_class!
610
+ end
611
+
612
+ # Strategy 1: walk Relation entities. Parcel files are
613
+ # relation-centric — each Relation row says "this domain
614
+ # entity is linked to this codomain entity via this relation
615
+ # type." Predication and function relations imply
616
+ # "Klass declares Property" (or vice versa).
617
+ def link_property_classes_via_relations!
618
+ relations.each do |r|
619
+ next unless r.predication? || r.function?
620
+ next if r.domain_irdis.empty?
621
+ next unless r.codomain_irdi
622
+ r.domain_irdis.each do |d|
623
+ next unless d
624
+ src = find(d)
625
+ dst = find(r.codomain_irdi)
626
+ next unless src && dst
627
+
628
+ if src.is_a?(Opencdd::Klass) && dst.is_a?(Opencdd::Property)
629
+ src.declare_property(dst.irdi)
630
+ @class_by_property_irdi[dst.irdi] << src unless @class_by_property_irdi[dst.irdi].include?(src)
631
+ elsif src.is_a?(Opencdd::Property) && dst.is_a?(Opencdd::Klass)
632
+ dst.declare_property(src.irdi)
633
+ @class_by_property_irdi[src.irdi] << dst unless @class_by_property_irdi[src.irdi].include?(dst)
634
+ end
635
+ end
636
+ end
637
+ end
638
+
639
+ # Strategy 2: walk Property entities' definition_class
640
+ # (MDC_P021). CDDAL files are often property-centric and
641
+ # don't carry explicit Relation rows — the link is implied
642
+ # by each property's definition_class field. declare_property
643
+ # deduplicates, so running both strategies is safe.
644
+ def link_property_classes_via_definition_class!
645
+ properties.each do |prop|
646
+ dc_raw = prop.properties[Opencdd::PropertyIds::MDC_P021]
647
+ next unless dc_raw
648
+ target = resolve_reference(dc_raw)
649
+ next unless target.is_a?(Opencdd::Klass)
650
+ target.declare_property(prop.irdi)
651
+ @class_by_property_irdi[prop.irdi] << target unless @class_by_property_irdi[prop.irdi].include?(target)
652
+ end
653
+ end
654
+
655
+ def link_value_lists!
656
+ properties.each do |prop|
657
+ next unless prop.enum?
658
+ identifier = value_list_identifier_of(prop)
659
+ vl_irdi = identifier && resolve_reference(identifier)&.irdi
660
+ next unless vl_irdi
661
+ vl = find(vl_irdi)
662
+ next unless vl.is_a?(Opencdd::ValueList)
663
+ @class_by_property_irdi[prop.irdi] << vl unless @class_by_property_irdi[prop.irdi].include?(vl)
664
+ end
665
+ end
666
+
667
+ def validate_parcel_id!(parcel_id)
668
+ unless parcel_id.is_a?(String) && parcel_id.match?(PARCEL_ID_PATTERN)
669
+ raise ArgumentError, "invalid parcel_id: #{parcel_id.inspect}"
670
+ end
671
+ end
672
+
673
+ def normalize_meta_class_irdis(raw)
674
+ codes = Array(raw).map { |v| v.to_s.split("#").last }
675
+ FORCED_META_CLASSES.each { |c| codes << c unless codes.include?(c) }
676
+ codes.uniq
677
+ end
678
+
679
+ def build_sheetmap_for(parcel_id, sheets)
680
+ entries = [
681
+ Opencdd::Parcel::Workbook::SheetMapEntry.new(
682
+ project_id: parcel_id, parcel_id: parcel_id, class_irdi: nil,
683
+ content_no: 0, sheet_no: 2, sheet_name: "pcls_LOCAL",
684
+ type: "PARCEL_LIST", target: "",
685
+ ),
686
+ ]
687
+ sheets.each_with_index do |sheet, idx|
688
+ entries << Opencdd::Parcel::Workbook::SheetMapEntry.new(
689
+ project_id: parcel_id, parcel_id: parcel_id,
690
+ class_irdi: sheet.meta_class_irdi,
691
+ content_no: 0, sheet_no: idx + 3,
692
+ sheet_name: sheet.name,
693
+ type: Opencdd::MetaClasses.sheet_type_for_type(Opencdd::MetaClasses.type_for(sheet.meta_class_code)),
694
+ target: "",
695
+ )
696
+ end
697
+ entries
698
+ end
699
+
700
+ def parse_translation_languages(workbook)
701
+ multi = workbook.project&.multi_language.to_s
702
+ multi.split(",").map(&:strip).reject(&:empty?)
703
+ end
704
+
705
+ def remove_entity_by_irdi!(irdi)
706
+ entity = @entities_by_irdi.delete(irdi)
707
+ return unless entity
708
+ if entity.code
709
+ list = @entities_by_code[entity.code]
710
+ list.delete(entity)
711
+ @entities_by_code.delete(entity.code) if list.empty?
712
+ end
713
+ if entity.type
714
+ @entities_by_type[entity.type].delete(entity)
715
+ end
716
+ @symbol_table.delete_if { |_, e| e.equal?(entity) }
717
+ end
718
+ end
719
+ end