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.
- checksums.yaml +7 -0
- data/CLAUDE.md +486 -0
- data/README.md +304 -0
- data/bin/lint-no-raw-mdc +41 -0
- data/lib/cdd.rb +11 -0
- data/lib/opencdd/alias_table.rb +52 -0
- data/lib/opencdd/cddal/ast.rb +151 -0
- data/lib/opencdd/cddal/builder.rb +374 -0
- data/lib/opencdd/cddal/fetcher/in_memory.rb +32 -0
- data/lib/opencdd/cddal/fetcher/net_http.rb +84 -0
- data/lib/opencdd/cddal/fetcher.rb +18 -0
- data/lib/opencdd/cddal/generated_parser.rb +805 -0
- data/lib/opencdd/cddal/lexer.rb +193 -0
- data/lib/opencdd/cddal/parser.rb +19 -0
- data/lib/opencdd/cddal/resolver.rb +100 -0
- data/lib/opencdd/cddal/serializer.rb +210 -0
- data/lib/opencdd/cddal/value_serializer.rb +60 -0
- data/lib/opencdd/cddal.rb +63 -0
- data/lib/opencdd/class_tree.rb +80 -0
- data/lib/opencdd/class_type.rb +33 -0
- data/lib/opencdd/codegen/ts.rb +185 -0
- data/lib/opencdd/codegen.rb +7 -0
- data/lib/opencdd/composition_tree.rb +119 -0
- data/lib/opencdd/condition.rb +120 -0
- data/lib/opencdd/data_type.rb +143 -0
- data/lib/opencdd/database.rb +719 -0
- data/lib/opencdd/effective_properties.rb +119 -0
- data/lib/opencdd/entity/field_reader.rb +141 -0
- data/lib/opencdd/entity/field_registry.rb +99 -0
- data/lib/opencdd/entity/version_history.rb +62 -0
- data/lib/opencdd/entity.rb +255 -0
- data/lib/opencdd/exporters/json.rb +235 -0
- data/lib/opencdd/exporters/mermaid.rb +62 -0
- data/lib/opencdd/exporters/yaml.rb +16 -0
- data/lib/opencdd/exporters.rb +9 -0
- data/lib/opencdd/guid.rb +27 -0
- data/lib/opencdd/instance_rule.rb +70 -0
- data/lib/opencdd/irdi.rb +128 -0
- data/lib/opencdd/klass.rb +230 -0
- data/lib/opencdd/languages.rb +70 -0
- data/lib/opencdd/meta_class.rb +274 -0
- data/lib/opencdd/model/entity_store.rb +85 -0
- data/lib/opencdd/model/yaml_database.rb +49 -0
- data/lib/opencdd/model/yaml_entity.rb +196 -0
- data/lib/opencdd/model.rb +13 -0
- data/lib/opencdd/parcel/csv_reader.rb +35 -0
- data/lib/opencdd/parcel/csv_writer.rb +114 -0
- data/lib/opencdd/parcel/entity_manifest.rb +119 -0
- data/lib/opencdd/parcel/flat_dir_reader.rb +134 -0
- data/lib/opencdd/parcel/layout_detector.rb +115 -0
- data/lib/opencdd/parcel/metadata.rb +112 -0
- data/lib/opencdd/parcel/referenced_irdis.rb +91 -0
- data/lib/opencdd/parcel/scrape_verifier.rb +122 -0
- data/lib/opencdd/parcel/selector.rb +115 -0
- data/lib/opencdd/parcel/sharded_dir_reader.rb +151 -0
- data/lib/opencdd/parcel/sheet.rb +287 -0
- data/lib/opencdd/parcel/sheet_emitter.rb +171 -0
- data/lib/opencdd/parcel/sheet_schema.rb +253 -0
- data/lib/opencdd/parcel/workbook.rb +172 -0
- data/lib/opencdd/parcel/workbook_reader.rb +200 -0
- data/lib/opencdd/parcel/writer.rb +185 -0
- data/lib/opencdd/parcel.rb +175 -0
- data/lib/opencdd/parse_helpers.rb +73 -0
- data/lib/opencdd/property.rb +120 -0
- data/lib/opencdd/property_data_element_type.rb +44 -0
- data/lib/opencdd/property_ids.rb +202 -0
- data/lib/opencdd/reader.rb +103 -0
- data/lib/opencdd/relation.rb +88 -0
- data/lib/opencdd/relation_tree.rb +74 -0
- data/lib/opencdd/relation_type.rb +58 -0
- data/lib/opencdd/structured_values.rb +183 -0
- data/lib/opencdd/unit.rb +16 -0
- data/lib/opencdd/validator/class_reference_rule.rb +77 -0
- data/lib/opencdd/validator/condition_rule.rb +27 -0
- data/lib/opencdd/validator/data_type_rule.rb +26 -0
- data/lib/opencdd/validator/enum_rule.rb +27 -0
- data/lib/opencdd/validator/format_rule.rb +54 -0
- data/lib/opencdd/validator/hierarchy_rule.rb +65 -0
- data/lib/opencdd/validator/irdi_rule.rb +57 -0
- data/lib/opencdd/validator/mandatory_rule.rb +25 -0
- data/lib/opencdd/validator/pattern_rule.rb +26 -0
- data/lib/opencdd/validator/reference_rule.rb +36 -0
- data/lib/opencdd/validator/rule.rb +65 -0
- data/lib/opencdd/validator/runner.rb +101 -0
- data/lib/opencdd/validator/set_rule.rb +41 -0
- data/lib/opencdd/validator/synonym_rule.rb +30 -0
- data/lib/opencdd/validator/type_rule.rb +102 -0
- data/lib/opencdd/validator/uniqueness_rule.rb +32 -0
- data/lib/opencdd/validator.rb +68 -0
- data/lib/opencdd/value_format.rb +71 -0
- data/lib/opencdd/value_list.rb +42 -0
- data/lib/opencdd/value_term.rb +24 -0
- data/lib/opencdd/version.rb +5 -0
- data/lib/opencdd/view_control.rb +10 -0
- data/lib/opencdd/visitor.rb +93 -0
- data/lib/opencdd.rb +57 -0
- 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
|