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,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Opencdd
|
|
6
|
+
module Cddal
|
|
7
|
+
# Source location attached to every entity created from CDDAL
|
|
8
|
+
# for diagnostics (validator messages, error attribution,
|
|
9
|
+
# cross-file navigation in the editor).
|
|
10
|
+
SourceLocation = Struct.new(:file, :line, keyword_init: true) do
|
|
11
|
+
def to_s
|
|
12
|
+
"#{file}:#{line || '?'}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Builds a +Opencdd::Database+ from a parsed CDDAL AST. Owns the
|
|
17
|
+
# module/import pipeline: resolves specifiers via the configured
|
|
18
|
+
# +Resolver+, fetches sources via the +Fetcher+, recursively
|
|
19
|
+
# builds imported documents into the same database, tracks the
|
|
20
|
+
# dependency graph for cycle detection, and applies bare,
|
|
21
|
+
# qualified, and selective import scoping rules.
|
|
22
|
+
class Builder
|
|
23
|
+
attr_reader :database, :alias_table, :symbol_table, :meta_class_overrides,
|
|
24
|
+
:source_file, :loaded_modules
|
|
25
|
+
|
|
26
|
+
def initialize(database = nil, resolver: nil, source_file: nil,
|
|
27
|
+
loaded_modules: nil, loading_stack: nil)
|
|
28
|
+
@database = database || Opencdd::Database.new
|
|
29
|
+
@resolver = resolver || Opencdd::Cddal.default_resolver
|
|
30
|
+
@source_file = source_file
|
|
31
|
+
@loaded_modules = loaded_modules || {}
|
|
32
|
+
@loading_stack = loading_stack || []
|
|
33
|
+
@alias_table = Opencdd::AliasTable.new(defaults: true)
|
|
34
|
+
@symbol_table = {}
|
|
35
|
+
@qualified_table = {} # "qualifier.name" => entity (qualified imports)
|
|
36
|
+
@instance_decls = []
|
|
37
|
+
@meta_class_overrides = {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build(document_or_declarations)
|
|
41
|
+
document = wrap_document(document_or_declarations)
|
|
42
|
+
apply_alias_declarations(document)
|
|
43
|
+
apply_meta_class_declarations(document)
|
|
44
|
+
apply_import_declarations(document)
|
|
45
|
+
register_instance_symbols(document)
|
|
46
|
+
instantiate_entities(document)
|
|
47
|
+
resolve_property_references(document)
|
|
48
|
+
# Linking (parent/child, declared_property_irdis, value lists,
|
|
49
|
+
# symbol table) lives in Database.finalize! — single source
|
|
50
|
+
# of truth for entity-graph invariants. Builder used to
|
|
51
|
+
# duplicate link_class_hierarchy / link_property_classes;
|
|
52
|
+
# those were folded into Database#link_property_classes!
|
|
53
|
+
# (which now runs both relation-based and definition_class-
|
|
54
|
+
# based strategies).
|
|
55
|
+
@database.finalize!
|
|
56
|
+
@database
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Resolve +specifier+ and return its source text without
|
|
60
|
+
# building it into the database. Used by the validator and
|
|
61
|
+
# by diagnostics that want to peek at imports.
|
|
62
|
+
def peek_import(specifier)
|
|
63
|
+
@resolver.resolve(specifier, importing_file: @source_file)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def wrap_document(document_or_declarations)
|
|
69
|
+
return document_or_declarations if document_or_declarations.is_a?(AST::Document)
|
|
70
|
+
AST::Document.new(declarations: Array(document_or_declarations))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def apply_alias_declarations(document)
|
|
74
|
+
document.alias_declarations.each do |decl|
|
|
75
|
+
@alias_table.declare(decl.alias_name, decl.property_id)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def apply_meta_class_declarations(document)
|
|
80
|
+
document.meta_class_declarations.each do |decl|
|
|
81
|
+
meta = Opencdd::MetaClass::MetaClasses.for(decl.irdi)
|
|
82
|
+
extension = Opencdd::MetaClass.new(
|
|
83
|
+
irdi: decl.irdi,
|
|
84
|
+
name: meta&.name || decl.irdi,
|
|
85
|
+
entity_class: meta&.entity_class,
|
|
86
|
+
allowed_property_ids: decl.property_ids.map { |id| resolve_property_id(id) },
|
|
87
|
+
)
|
|
88
|
+
@meta_class_overrides[decl.irdi] = extension
|
|
89
|
+
Opencdd::MetaClass::MetaClasses.register(extension)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ── Module/import pipeline ─────────────────────────────────
|
|
94
|
+
#
|
|
95
|
+
# Three import kinds share the same resolution + cycle-check
|
|
96
|
+
# + recursive-build core. They differ only in how the imported
|
|
97
|
+
# module's named declarations are surfaced in the parent
|
|
98
|
+
# document's symbol table:
|
|
99
|
+
#
|
|
100
|
+
# bare — every name from the target is in scope.
|
|
101
|
+
# qualified — name accessible as "<qualifier>.<name>".
|
|
102
|
+
# selective — only the listed names are in scope, each
|
|
103
|
+
# optionally renamed via "as".
|
|
104
|
+
#
|
|
105
|
+
# All entities from the imported module are added to the parent
|
|
106
|
+
# Database regardless of import kind — IRDI references remain
|
|
107
|
+
# resolvable. The import kind controls only which symbolic
|
|
108
|
+
# names are accessible without qualification.
|
|
109
|
+
|
|
110
|
+
def apply_import_declarations(document)
|
|
111
|
+
document.import_declarations.each { |decl| process_import(decl) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def process_import(decl)
|
|
115
|
+
canonical, source = resolve_specifier(decl.specifier)
|
|
116
|
+
# Resolution may return [nil, nil] in non-strict mode when
|
|
117
|
+
# the specifier points at an unreachable URL or a missing
|
|
118
|
+
# file. Skip the import with a warning — preserves the
|
|
119
|
+
# graceful-degradation behavior CDDAL authors expect for
|
|
120
|
+
# cross-dictionary URLs.
|
|
121
|
+
return if canonical.nil?
|
|
122
|
+
return if @loaded_modules.key?(canonical)
|
|
123
|
+
check_cycle!(canonical, decl.specifier)
|
|
124
|
+
|
|
125
|
+
@loading_stack.push(canonical)
|
|
126
|
+
sub_doc = Opencdd::Cddal::Parser.parse(source)
|
|
127
|
+
Builder.new(@database, resolver: @resolver, source_file: canonical,
|
|
128
|
+
loaded_modules: @loaded_modules,
|
|
129
|
+
loading_stack: @loading_stack)
|
|
130
|
+
.build(sub_doc)
|
|
131
|
+
@loading_stack.pop
|
|
132
|
+
@loaded_modules[canonical] = true
|
|
133
|
+
|
|
134
|
+
# Symbol scoping is applied AFTER the sub-document is built,
|
|
135
|
+
# so the named entities exist in @database by the time we
|
|
136
|
+
# look them up.
|
|
137
|
+
apply_import_scope(decl, canonical)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def resolve_specifier(specifier)
|
|
141
|
+
@resolver.resolve(specifier, importing_file: @source_file)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def check_cycle!(canonical, specifier)
|
|
145
|
+
return unless @loading_stack.include?(canonical)
|
|
146
|
+
cycle = @loading_stack.dup
|
|
147
|
+
cycle << canonical
|
|
148
|
+
raise Opencdd::Cddal::ImportError,
|
|
149
|
+
"circular CDDAL import detected: #{cycle.join(' → ')} " \
|
|
150
|
+
"(originally imported as #{specifier.inspect})"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def apply_import_scope(decl, _canonical)
|
|
154
|
+
case decl.kind
|
|
155
|
+
when :bare
|
|
156
|
+
# Nothing to do — bare imports leave the sub-document's
|
|
157
|
+
# names in the shared @database symbol table.
|
|
158
|
+
when :qualified
|
|
159
|
+
register_qualified_symbols(decl.qualifier)
|
|
160
|
+
when :selective
|
|
161
|
+
register_selective_symbols(decl.imported_names)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def register_qualified_symbols(qualifier)
|
|
166
|
+
@database.entities.each do |entity|
|
|
167
|
+
name = entity_alias_name(entity)
|
|
168
|
+
next unless name
|
|
169
|
+
qualified = "#{qualifier}.#{name}"
|
|
170
|
+
@qualified_table[qualified] = entity
|
|
171
|
+
@database.register_symbol(qualified, entity)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def register_selective_symbols(imported_names)
|
|
176
|
+
imported_names.each do |imported|
|
|
177
|
+
entity = @database.resolve_reference(imported.name)
|
|
178
|
+
next unless entity
|
|
179
|
+
# Register in the parent Database's symbol table so the
|
|
180
|
+
# renamed name resolves during link phase.
|
|
181
|
+
@database.register_symbol(imported.local_name, entity)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def entity_alias_name(entity)
|
|
186
|
+
code = entity.code
|
|
187
|
+
return code if code && !code.empty?
|
|
188
|
+
entity.preferred_name.to_s
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def register_instance_symbols(document)
|
|
192
|
+
document.instance_declarations.each do |decl|
|
|
193
|
+
@instance_decls << decl
|
|
194
|
+
register_symbol_name(decl.name, decl) if decl.name
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def register_symbol_name(name, decl)
|
|
199
|
+
key = name.to_s
|
|
200
|
+
if @symbol_table.key?(key) && @symbol_table[key] != decl
|
|
201
|
+
warn "Symbol #{key.inspect} already declared; ignoring redefinition"
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
@symbol_table[key] = decl
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def instantiate_entities(document)
|
|
208
|
+
@instance_decls.each do |decl|
|
|
209
|
+
entity = build_entity(decl)
|
|
210
|
+
next unless entity
|
|
211
|
+
attach_source_location(entity, decl)
|
|
212
|
+
@database.add_entity(entity)
|
|
213
|
+
@database.register_symbol(decl.name, entity) if decl.name
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def attach_source_location(entity, decl)
|
|
218
|
+
loc = SourceLocation.new(file: @source_file, line: decl.line)
|
|
219
|
+
entity.attach_source_location(loc)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def build_entity(decl)
|
|
223
|
+
meta_class = resolve_meta_class(decl.meta_class_ref)
|
|
224
|
+
return nil unless meta_class && meta_class.entity_class
|
|
225
|
+
|
|
226
|
+
properties = build_properties(decl)
|
|
227
|
+
irdi = extract_irdi(properties)
|
|
228
|
+
meta_irdi = Opencdd::IRDI.parse(meta_class.irdi)
|
|
229
|
+
|
|
230
|
+
meta_class.entity_class.new(
|
|
231
|
+
irdi: irdi,
|
|
232
|
+
properties: properties,
|
|
233
|
+
schema: nil,
|
|
234
|
+
meta_class_irdi: meta_irdi,
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def resolve_meta_class(ref)
|
|
239
|
+
return nil unless ref
|
|
240
|
+
return @meta_class_overrides[ref] if @meta_class_overrides.key?(ref)
|
|
241
|
+
return Opencdd::MetaClass::MetaClasses.for(ref) if ref.to_s.match?(/\AMDC_C\d+\z/) || ref.to_s.match?(/\AEXT_C\d+\z/)
|
|
242
|
+
|
|
243
|
+
canonical = resolve_property_id(ref)
|
|
244
|
+
Opencdd::MetaClass::MetaClasses.for(canonical) if canonical
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def build_properties(decl)
|
|
248
|
+
# Resolve the meta-class once so per-assignment code-alias
|
|
249
|
+
# resolution can be context-aware. Without this, a CDDAL
|
|
250
|
+
# `code: AAAP001` on a Property stores the value under
|
|
251
|
+
# MDC_P001_5 (the Class code) — Parcel readers look for
|
|
252
|
+
# MDC_P001_6 (the Property code) and miss it, dropping the
|
|
253
|
+
# entity on round-trip.
|
|
254
|
+
meta_class_code = resolve_meta_class_code(decl.meta_class_ref)
|
|
255
|
+
canonical_code_pid = meta_class_code &&
|
|
256
|
+
Opencdd::MetaClasses.code_property_id_for(meta_class_code)
|
|
257
|
+
|
|
258
|
+
props = {}
|
|
259
|
+
decl.assignments.each do |assignment|
|
|
260
|
+
property_id = resolve_property_id(
|
|
261
|
+
assignment.identifier,
|
|
262
|
+
canonical_code_pid: canonical_code_pid,
|
|
263
|
+
)
|
|
264
|
+
key = compose_property_key(property_id, assignment.language_tag)
|
|
265
|
+
value = serialize_value(assignment.value, property_id)
|
|
266
|
+
props[key] = merge_property_value(props[key], value)
|
|
267
|
+
end
|
|
268
|
+
props
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def compose_property_key(property_id, language_tag)
|
|
272
|
+
return property_id unless language_tag
|
|
273
|
+
"#{property_id}.#{language_tag}"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def merge_property_value(existing, new_value)
|
|
277
|
+
if existing.nil?
|
|
278
|
+
new_value
|
|
279
|
+
elsif existing.is_a?(Array) || new_value.is_a?(Array)
|
|
280
|
+
Array(existing) + Array(new_value)
|
|
281
|
+
else
|
|
282
|
+
new_value
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def resolve_meta_class_code(ref)
|
|
287
|
+
return nil unless ref
|
|
288
|
+
meta_class = resolve_meta_class(ref)
|
|
289
|
+
meta_class&.irdi
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Resolve an alias or property name to its canonical property ID.
|
|
293
|
+
#
|
|
294
|
+
# When +canonical_code_pid:+ is supplied (the meta-class's
|
|
295
|
+
# canonical code property), and the resolved ID is one of the
|
|
296
|
+
# MDC_P001_X code variants for a *different* meta-class,
|
|
297
|
+
# we redirect to the canonical. This makes the CDDAL `code`
|
|
298
|
+
# alias context-aware: writing `code: AAA001` on a Property
|
|
299
|
+
# stores the value under MDC_P001_6, not MDC_P001_5.
|
|
300
|
+
def resolve_property_id(name, canonical_code_pid: nil)
|
|
301
|
+
s = name.to_s
|
|
302
|
+
return s if s.match?(/\AMDC_P\d+/) || s.match?(/\AEXT_P\d+/)
|
|
303
|
+
resolved = @alias_table.resolve(s) || s
|
|
304
|
+
return resolved unless canonical_code_pid
|
|
305
|
+
return resolved if resolved == canonical_code_pid
|
|
306
|
+
# Only redirect when the resolved ID is one of the per-meta-class
|
|
307
|
+
# code variants AND we have a different canonical in scope.
|
|
308
|
+
if CODE_VARIANT_IDS.include?(resolved)
|
|
309
|
+
canonical_code_pid
|
|
310
|
+
else
|
|
311
|
+
resolved
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# All per-meta-class code property IDs. Used by
|
|
316
|
+
# +resolve_property_id+ to recognize code aliases that should
|
|
317
|
+
# be redirected to the meta-class's canonical code property.
|
|
318
|
+
CODE_VARIANT_IDS = %w[
|
|
319
|
+
MDC_P001 MDC_P001_1 MDC_P001_2 MDC_P001_5 MDC_P001_6 MDC_P001_7
|
|
320
|
+
MDC_P001_8 MDC_P001_10 MDC_P001_11 MDC_P001_12 MDC_P001_13
|
|
321
|
+
EXT_P001
|
|
322
|
+
].freeze
|
|
323
|
+
private_constant :CODE_VARIANT_IDS
|
|
324
|
+
|
|
325
|
+
def serialize_value(value, property_id)
|
|
326
|
+
Opencdd::Cddal::ValueSerializer.serialize(value, property_id)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def extract_irdi(properties)
|
|
330
|
+
raw = properties[Opencdd::PropertyIds::MDC_P001_5] || properties[Opencdd::PropertyIds::MDC_P001_6] ||
|
|
331
|
+
properties[Opencdd::PropertyIds::MDC_P001_10] || properties[Opencdd::PropertyIds::MDC_P001_11] ||
|
|
332
|
+
properties[Opencdd::PropertyIds::MDC_P001_12] || properties[Opencdd::PropertyIds::MDC_P001_13]
|
|
333
|
+
return nil unless raw
|
|
334
|
+
Opencdd::IRDI.parse(raw.to_s)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def resolve_property_references(document)
|
|
338
|
+
reference_kinds = %i[identifier_ref set_of_refs class_ref].freeze
|
|
339
|
+
reference_property_ids = Set.new(
|
|
340
|
+
Opencdd::PropertyIds::REGISTRY.select { |_, e| reference_kinds.include?(e.value_kind) }.keys
|
|
341
|
+
)
|
|
342
|
+
@database.entities.each do |entity|
|
|
343
|
+
properties = entity.properties
|
|
344
|
+
properties.each do |key, value|
|
|
345
|
+
base = key.to_s.sub(/\.\w+\z/, "")
|
|
346
|
+
next unless reference_property_ids.include?(base)
|
|
347
|
+
next if value.nil? || value.to_s.strip.empty?
|
|
348
|
+
resolved = resolve_property_value(value)
|
|
349
|
+
properties[key] = resolved if resolved != value
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def resolve_property_value(value)
|
|
355
|
+
s = value.to_s.strip
|
|
356
|
+
return resolve_single_reference(s) unless collection_form?(s)
|
|
357
|
+
# SSOT: top-level-aware split via StructuredValues.
|
|
358
|
+
elements = Opencdd::StructuredValues.unwrap_and_split(s)
|
|
359
|
+
resolved = elements.map { |e| resolve_single_reference(e) }.compact
|
|
360
|
+
Opencdd::StructuredValues.rejoin(resolved)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def collection_form?(s)
|
|
364
|
+
(s.start_with?("(") && s.end_with?(")")) ||
|
|
365
|
+
(s.start_with?("{") && s.end_with?("}"))
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def resolve_single_reference(token)
|
|
369
|
+
target = @database.resolve_reference(token)
|
|
370
|
+
target&.irdi&.to_s || token
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencdd
|
|
4
|
+
module Cddal
|
|
5
|
+
module Fetcher
|
|
6
|
+
# Test fetcher. Holds an in-memory URL → source map; specs
|
|
7
|
+
# construct one with the fixtures they want to serve. Avoids
|
|
8
|
+
# any network dependency in the test suite.
|
|
9
|
+
class InMemory
|
|
10
|
+
attr_reader :map
|
|
11
|
+
|
|
12
|
+
def initialize(map = {})
|
|
13
|
+
@map = {}
|
|
14
|
+
map.each { |k, v| @map[k.to_s] = v.to_s }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch(url)
|
|
18
|
+
key = url.to_s
|
|
19
|
+
@map.fetch(key) do
|
|
20
|
+
raise Opencdd::Cddal::ImportError,
|
|
21
|
+
"no in-memory fixture registered for #{key}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def register(url, source)
|
|
26
|
+
@map[url.to_s] = source.to_s
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module Opencdd
|
|
8
|
+
module Cddal
|
|
9
|
+
module Fetcher
|
|
10
|
+
# Default URL fetcher. Fetches via +Net::HTTP+, caches the
|
|
11
|
+
# response body to +cache_dir+ keyed by URL hash, and supports
|
|
12
|
+
# an +offline:+ mode that serves only cached content (used in
|
|
13
|
+
# CI to keep test runs hermetic).
|
|
14
|
+
class NetHttp
|
|
15
|
+
attr_reader :cache_dir, :offline
|
|
16
|
+
|
|
17
|
+
def initialize(cache_dir: default_cache_dir, offline: false)
|
|
18
|
+
@cache_dir = cache_dir ? Pathname.new(cache_dir.to_s) : nil
|
|
19
|
+
@offline = offline
|
|
20
|
+
@memory_cache = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fetch(url)
|
|
24
|
+
key = url.to_s
|
|
25
|
+
return @memory_cache[key] if @memory_cache.key?(key)
|
|
26
|
+
|
|
27
|
+
text = if @offline
|
|
28
|
+
read_from_disk_cache(key) || raise_import_error(key)
|
|
29
|
+
else
|
|
30
|
+
fetch_http(key)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@memory_cache[key] = text
|
|
34
|
+
write_to_disk_cache(key, text) if @cache_dir && !@offline
|
|
35
|
+
text
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def default_cache_dir
|
|
41
|
+
ENV["CDDAL_CACHE"] || File.join(Dir.tmpdir, "cddal-cache")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_http(url)
|
|
45
|
+
require "net/http"
|
|
46
|
+
require "uri"
|
|
47
|
+
uri = URI.parse(url)
|
|
48
|
+
response = Net::HTTP.get_response(uri)
|
|
49
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
50
|
+
raise Opencdd::Cddal::ImportError,
|
|
51
|
+
"HTTP #{response.code} #{response.message} for #{url}"
|
|
52
|
+
end
|
|
53
|
+
response.body
|
|
54
|
+
rescue Opencdd::Cddal::ImportError
|
|
55
|
+
raise
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
raise Opencdd::Cddal::ImportError, "fetch failed for #{url}: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read_from_disk_cache(url)
|
|
61
|
+
return nil unless @cache_dir
|
|
62
|
+
path = cache_path_for(url)
|
|
63
|
+
path.exist? ? path.read : nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def write_to_disk_cache(url, text)
|
|
67
|
+
path = cache_path_for(url)
|
|
68
|
+
path.dirname.mkpath
|
|
69
|
+
path.write(text) unless path.exist?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def cache_path_for(url)
|
|
73
|
+
hash = Digest::SHA256.hexdigest(url)
|
|
74
|
+
@cache_dir.join("#{hash}.cddal")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def raise_import_error(url)
|
|
78
|
+
raise Opencdd::Cddal::ImportError,
|
|
79
|
+
"offline mode and no cached copy for #{url}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Opencdd
|
|
7
|
+
module Cddal
|
|
8
|
+
# Pluggable fetcher for URL-based CDDAL imports. The default
|
|
9
|
+
# +NetHttp+ implementation honors an on-disk cache and an
|
|
10
|
+
# +offline:+ flag (used in CI to avoid network dependence).
|
|
11
|
+
# Tests use +InMemory+ to stub URL fetching without touching
|
|
12
|
+
# the network.
|
|
13
|
+
module Fetcher
|
|
14
|
+
autoload :NetHttp, "opencdd/cddal/fetcher/net_http"
|
|
15
|
+
autoload :InMemory, "opencdd/cddal/fetcher/in_memory"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|