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,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Opencdd
|
|
6
|
+
class EffectiveProperties
|
|
7
|
+
Result = Struct.new(:properties, :sources, keyword_init: true) do
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
def each
|
|
11
|
+
return enum_for(:each) unless block_given?
|
|
12
|
+
properties.each { |p| yield p }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def each_property
|
|
16
|
+
return enum_for(:each_property) unless block_given?
|
|
17
|
+
properties.each { |p| yield p }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def size
|
|
21
|
+
properties.size
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def include?(property)
|
|
25
|
+
irdi = property.is_a?(Opencdd::IRDI) ? property : property&.irdi
|
|
26
|
+
properties.any? { |p| p.irdi == irdi }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def codes
|
|
30
|
+
properties.map(&:code)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_a
|
|
34
|
+
properties.dup
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :database
|
|
39
|
+
|
|
40
|
+
def initialize(database)
|
|
41
|
+
@database = database
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def for(klass)
|
|
45
|
+
k = resolve_klass(klass)
|
|
46
|
+
return Result.new(properties: [], sources: {}) unless k
|
|
47
|
+
acc = []
|
|
48
|
+
sources = Hash.new { |h, kk| h[kk] = [] }
|
|
49
|
+
accumulate(k, Set.new, acc, sources)
|
|
50
|
+
Result.new(properties: acc.uniq { |p| p.irdi }, sources: sources)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def codes_for(klass)
|
|
54
|
+
result = self.for(klass)
|
|
55
|
+
result.properties.map(&:code)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def resolve_klass(value)
|
|
61
|
+
return value if value.is_a?(Opencdd::Klass)
|
|
62
|
+
@database.resolve_reference(value)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def accumulate(klass, seen, acc, sources)
|
|
66
|
+
return if seen.include?(klass.irdi)
|
|
67
|
+
seen << klass.irdi
|
|
68
|
+
|
|
69
|
+
add_declared_properties(klass, acc, sources)
|
|
70
|
+
add_applicable_properties(klass, acc, sources)
|
|
71
|
+
add_imported_properties(klass, acc, sources)
|
|
72
|
+
walk_superclass(klass, seen, acc, sources)
|
|
73
|
+
walk_is_case_of(klass, seen, acc, sources)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def add_declared_properties(klass, acc, sources)
|
|
77
|
+
klass.declared_property_irdis.each do |irdi|
|
|
78
|
+
prop = @database.find(irdi)
|
|
79
|
+
next unless prop
|
|
80
|
+
acc << prop
|
|
81
|
+
sources[prop.irdi.to_s] << klass unless sources[prop.irdi.to_s].include?(klass)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def add_applicable_properties(klass, acc, sources)
|
|
86
|
+
klass.applicable_property_irdis.each do |irdi|
|
|
87
|
+
prop = @database.find(irdi)
|
|
88
|
+
next unless prop
|
|
89
|
+
acc << prop
|
|
90
|
+
sources[prop.irdi.to_s] << klass unless sources[prop.irdi.to_s].include?(klass)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def add_imported_properties(klass, acc, sources)
|
|
95
|
+
klass.imported_property_irdis.each do |irdi|
|
|
96
|
+
prop = @database.find(irdi)
|
|
97
|
+
next unless prop
|
|
98
|
+
acc << prop
|
|
99
|
+
sources[prop.irdi.to_s] << klass unless sources[prop.irdi.to_s].include?(klass)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def walk_superclass(klass, seen, acc, sources)
|
|
104
|
+
ref = klass.parent_irdi || klass.superclass_irdi
|
|
105
|
+
return unless ref
|
|
106
|
+
parent = @database.find(ref)
|
|
107
|
+
return unless parent
|
|
108
|
+
accumulate(parent, seen, acc, sources)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def walk_is_case_of(klass, seen, acc, sources)
|
|
112
|
+
klass.is_case_of_irdis.each do |ref|
|
|
113
|
+
target = @database.find(ref)
|
|
114
|
+
next unless target
|
|
115
|
+
accumulate(target, seen, acc, sources)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencdd
|
|
4
|
+
class Entity
|
|
5
|
+
# Typed reader for field declarations. Knows how to extract a
|
|
6
|
+
# value of a given kind from an entity's properties hash. The
|
|
7
|
+
# single source of truth for "what does MDC_P004.en look like"
|
|
8
|
+
# so the Json exporter, validators, and TS codegen all agree.
|
|
9
|
+
module FieldReader
|
|
10
|
+
class << self
|
|
11
|
+
# Read the +name+ field from +entity+. Multilingual fields
|
|
12
|
+
# accept a +lang:+ keyword and fall back to source-language
|
|
13
|
+
# (stored under the bare `MDC_P###` key) when the requested
|
|
14
|
+
# language is missing.
|
|
15
|
+
def read(entity, name, lang: nil)
|
|
16
|
+
entry = FieldRegistry.field_for(entity.class, name)
|
|
17
|
+
return nil unless entry
|
|
18
|
+
|
|
19
|
+
# Synthetic fields defined with a block: evaluate the block
|
|
20
|
+
# in the entity's context via instance_exec. This preserves
|
|
21
|
+
# access to private helper methods without using send to
|
|
22
|
+
# bypass privacy (encapsulation rule from CLAUDE.md).
|
|
23
|
+
return entity.instance_exec(&entry.block) if entry.block?
|
|
24
|
+
|
|
25
|
+
# Legacy form: synthetic fields name a public reader method.
|
|
26
|
+
# public_send is safe here because readers are public API.
|
|
27
|
+
return entity.public_send(entry.reader) if entry.synthetic? && entry.reader
|
|
28
|
+
|
|
29
|
+
raw =
|
|
30
|
+
if entry.multilingual?
|
|
31
|
+
raw_multilingual(entity, entry, lang)
|
|
32
|
+
else
|
|
33
|
+
entity.properties[entry.property_id]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
coerce(raw, entry.value_kind)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def raw_multilingual(entity, entry, lang)
|
|
42
|
+
props = entity.properties
|
|
43
|
+
# The scrape stores multilingual values under "<id>.<lang>"
|
|
44
|
+
# (e.g. "MDC_P004.en", "MDC_P004.de"). Some legacy data
|
|
45
|
+
# omits the suffix entirely ("MDC_P004" with no .en). When
|
|
46
|
+
# no lang is requested, default to "en" — the historical
|
|
47
|
+
# source language for all IEC CDD dictionaries.
|
|
48
|
+
effective_lang = lang || "en"
|
|
49
|
+
props["#{entry.property_id}.#{effective_lang}"] ||
|
|
50
|
+
props[entry.property_id] ||
|
|
51
|
+
props["#{entry.property_id}.en"]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Convert +raw+ (a String from the .xls) into the typed
|
|
55
|
+
# Ruby value the model expects. Collection kinds (:set_of_refs,
|
|
56
|
+
# :synonym_pairs) return empty arrays for nil/blank input so
|
|
57
|
+
# callers can safely iterate without nil checks — mirrors the
|
|
58
|
+
# legacy hand-written accessors.
|
|
59
|
+
def coerce(raw, value_kind)
|
|
60
|
+
case value_kind
|
|
61
|
+
when :string, :date, :date_time, :condition
|
|
62
|
+
raw.nil? ? nil : raw.to_s
|
|
63
|
+
when :irdi, :identifier_ref, :class_ref
|
|
64
|
+
parse_irdi(raw)
|
|
65
|
+
when :set_of_refs
|
|
66
|
+
parse_irdi_list_via_helpers(raw)
|
|
67
|
+
when :string_list
|
|
68
|
+
parse_string_list_via_helpers(raw)
|
|
69
|
+
when :synonym_pairs
|
|
70
|
+
parse_pair_list_via_helpers(raw)
|
|
71
|
+
when :integer
|
|
72
|
+
raw.nil? ? nil : (Integer(raw) rescue raw)
|
|
73
|
+
when :boolean
|
|
74
|
+
raw.nil? ? nil : parse_boolean(raw)
|
|
75
|
+
else
|
|
76
|
+
raw
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parse_irdi(raw)
|
|
81
|
+
return nil if raw.to_s.strip.empty?
|
|
82
|
+
Opencdd::IRDI.parse(raw)
|
|
83
|
+
rescue Opencdd::IRDI::ParseError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Parcel's set_of_refs and synonym_pairs wire shapes are
|
|
88
|
+
# delimited with braces/parens and commas. Parsed inline
|
|
89
|
+
# rather than via Opencdd::ParseHelpers because ParseHelpers
|
|
90
|
+
# methods are private (intended for mixin within Entity).
|
|
91
|
+
def parse_irdi_list_via_helpers(raw)
|
|
92
|
+
return [] if raw.to_s.strip.empty?
|
|
93
|
+
stripped = raw.to_s.strip.sub(/\A[\{\(\[]+/, "").sub(/[\}\)\]]+\z/, "")
|
|
94
|
+
stripped
|
|
95
|
+
.split(/[,;\s]+/)
|
|
96
|
+
.reject(&:empty?)
|
|
97
|
+
.filter_map { |s| parse_irdi(s) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_pair_list_via_helpers(raw)
|
|
101
|
+
return [] if raw.to_s.strip.empty?
|
|
102
|
+
stripped = raw.to_s.strip.sub(/\A[\{\(\[]+/, "").sub(/[\}\)\]]+\z/, "")
|
|
103
|
+
tokens = stripped.split(/[,;]+/).map(&:strip).reject(&:empty?)
|
|
104
|
+
# Parcel synonym wire shape alternates lang, name, lang, name:
|
|
105
|
+
# "(en, Foo, fr, Bidon)" → [["en", "Foo"], ["fr", "Bidon"]].
|
|
106
|
+
# If a token contains ":", treat as explicit "lang:name" pair.
|
|
107
|
+
# Otherwise group consecutive tokens into pairs.
|
|
108
|
+
if tokens.any? { |t| t.include?(":") }
|
|
109
|
+
tokens.map { |pair| parse_synonym_pair(pair) }.compact
|
|
110
|
+
else
|
|
111
|
+
tokens.each_slice(2).map { |a, b| b ? [a, b] : [nil, a] }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_string_list_via_helpers(raw)
|
|
116
|
+
return [] if raw.to_s.strip.empty?
|
|
117
|
+
stripped = raw.to_s.strip.sub(/\A[\{\(\[]+/, "").sub(/[\}\)\]]+\z/, "")
|
|
118
|
+
stripped.split(/[,;]+/).map(&:strip).reject(&:empty?)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_synonym_pair(pair)
|
|
122
|
+
if pair.include?(":")
|
|
123
|
+
lang, name = pair.split(":", 2).map(&:strip)
|
|
124
|
+
[lang.empty? ? nil : lang, name]
|
|
125
|
+
else
|
|
126
|
+
[nil, pair]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def parse_boolean(raw)
|
|
131
|
+
return raw unless raw.is_a?(String)
|
|
132
|
+
case raw.strip.downcase
|
|
133
|
+
when "true", "1", "yes", "y" then true
|
|
134
|
+
when "false", "0", "no", "n", "" then false
|
|
135
|
+
else raw
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencdd
|
|
4
|
+
class Entity
|
|
5
|
+
# Registry of declared fields on Entity subclasses. Open/closed:
|
|
6
|
+
# adding a field is a single `field` declaration in the entity
|
|
7
|
+
# class body — no edits to switch statements elsewhere.
|
|
8
|
+
module FieldRegistry
|
|
9
|
+
Entry = Struct.new(
|
|
10
|
+
:entity_class, :name, :property_id, :value_kind,
|
|
11
|
+
:multilingual, :synthetic, :reader, :block, :json_key,
|
|
12
|
+
keyword_init: true,
|
|
13
|
+
) do
|
|
14
|
+
def synthetic? = !!synthetic
|
|
15
|
+
def multilingual? = !!multilingual
|
|
16
|
+
|
|
17
|
+
# True when this field's value is computed by a block rather
|
|
18
|
+
# than read directly from +properties+. FieldReader dispatches
|
|
19
|
+
# block-defined fields via +instance_exec+, which preserves
|
|
20
|
+
# access to private helpers — no +send+ bypass required.
|
|
21
|
+
def block?
|
|
22
|
+
!block.nil?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The key to use when serializing this field to JSON. Defaults
|
|
26
|
+
# to the field's ruby name. Override via the `as:` DSL option
|
|
27
|
+
# when the wire-format name should differ from the method
|
|
28
|
+
# name (e.g. is_case_of_irdis → is_case_of).
|
|
29
|
+
def wire_name
|
|
30
|
+
(json_key || name).to_s
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@by_class = Hash.new { |h, k| h[k] = {} }
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# Register a field on +entity_class+. The DSL in Entity.field
|
|
38
|
+
# is the only intended caller. Re-registering the same name on
|
|
39
|
+
# the same class overwrites the prior declaration (useful for
|
|
40
|
+
# subclasses overriding the value_kind).
|
|
41
|
+
#
|
|
42
|
+
# Synthetic fields prefer the +block:+ form: the block is
|
|
43
|
+
# evaluated via +instance_exec+ on the entity when the field
|
|
44
|
+
# is read, so it has access to private helpers without
|
|
45
|
+
# requiring +send+ dispatch. The legacy +reader:+ form
|
|
46
|
+
# (a Symbol naming a public method on the entity) is accepted
|
|
47
|
+
# for back-compat but should not be used for new fields.
|
|
48
|
+
def register(entity_class:, name:, property_id:, value_kind:,
|
|
49
|
+
multilingual: false, synthetic: false, reader: nil,
|
|
50
|
+
block: nil, json_key: nil)
|
|
51
|
+
entry = Entry.new(
|
|
52
|
+
entity_class: entity_class,
|
|
53
|
+
name: name.to_sym,
|
|
54
|
+
property_id: property_id,
|
|
55
|
+
value_kind: value_kind,
|
|
56
|
+
multilingual: multilingual,
|
|
57
|
+
synthetic: synthetic,
|
|
58
|
+
reader: reader,
|
|
59
|
+
block: block,
|
|
60
|
+
json_key: json_key,
|
|
61
|
+
)
|
|
62
|
+
@by_class[entity_class][entry.name] = entry
|
|
63
|
+
entry
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Lookup a single field by name on +entity_class+. Walks up
|
|
67
|
+
# the ancestor chain so subclasses see inherited fields.
|
|
68
|
+
def field_for(entity_class, name)
|
|
69
|
+
each_entry(entity_class) { |e| return e if e.name == name.to_sym }
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Every field visible on +entity_class+, including inherited
|
|
74
|
+
# fields. Order: base-class declarations first, then subclass
|
|
75
|
+
# overrides (last wins on duplicate names).
|
|
76
|
+
def fields_for(entity_class)
|
|
77
|
+
seen = {}
|
|
78
|
+
each_entry(entity_class) { |e| seen[e.name] = e }
|
|
79
|
+
seen.values
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Walk ancestors from most-generic (Entity) to most-specific,
|
|
85
|
+
# yielding each declared entry along the way. Subclass
|
|
86
|
+
# declarations come last so they overwrite base-class entries
|
|
87
|
+
# with the same name (visible to #fields_for via the `seen`
|
|
88
|
+
# hash in callers).
|
|
89
|
+
def each_entry(entity_class)
|
|
90
|
+
entity_class.ancestors.reverse_each do |klass|
|
|
91
|
+
next unless klass.is_a?(Class)
|
|
92
|
+
entries = @by_class[klass]
|
|
93
|
+
entries.each_value { |e| yield e } if entries
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencdd
|
|
4
|
+
class Entity
|
|
5
|
+
# Model object for the per-version provenance captured in
|
|
6
|
+
# +downloads/<dict>/<CODE>/_entity.json#versions+. Each entry
|
|
7
|
+
# represents one historical version of the entity: which version
|
|
8
|
+
# and revision it was, its status at that point, who committed it,
|
|
9
|
+
# and the change-request ID.
|
|
10
|
+
#
|
|
11
|
+
# The ShardedDirReader parses +_entity.json+ for each class subdir
|
|
12
|
+
# and attaches a VersionHistory to every entity it creates. The
|
|
13
|
+
# Json exporter then emits it as an array of version entries,
|
|
14
|
+
# matching the IEC CDD "Version history" section on detail pages.
|
|
15
|
+
class VersionHistory
|
|
16
|
+
Entry = Struct.new(
|
|
17
|
+
:version, :revision, :status, :timestamp, :user,
|
|
18
|
+
:change_request_id, :unid, :is_current,
|
|
19
|
+
keyword_init: true,
|
|
20
|
+
) do
|
|
21
|
+
def current? = !!is_current
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
include Enumerable
|
|
25
|
+
|
|
26
|
+
def initialize(entries = [])
|
|
27
|
+
@entries = Array(entries)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :entries
|
|
31
|
+
|
|
32
|
+
def each(&block)
|
|
33
|
+
@entries.each(&block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def size
|
|
37
|
+
@entries.size
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def empty?
|
|
41
|
+
@entries.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def current
|
|
45
|
+
@entries.find(&:current?) || @entries.first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def previous
|
|
49
|
+
@entries.reject { |e| e == current }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_a
|
|
53
|
+
@entries.dup
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def attach(entry)
|
|
57
|
+
@entries << entry
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencdd
|
|
4
|
+
class Entity
|
|
5
|
+
include Opencdd::ParseHelpers
|
|
6
|
+
|
|
7
|
+
# Open/closed field declaration. Each `field :foo, "MDC_P###",
|
|
8
|
+
# :kind` call registers a typed accessor on the entity class and
|
|
9
|
+
# adds an entry to FieldRegistry. Adding a field is one line
|
|
10
|
+
# here — no edits to switch statements in the exporter,
|
|
11
|
+
# validators, or TS codegen (all read from FieldRegistry).
|
|
12
|
+
autoload :FieldRegistry, "opencdd/entity/field_registry"
|
|
13
|
+
autoload :FieldReader, "opencdd/entity/field_reader"
|
|
14
|
+
autoload :VersionHistory, "opencdd/entity/version_history"
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Declare a typed field on this entity class. Defaults for
|
|
18
|
+
# +value_kind+ and +multilingual+ are resolved from
|
|
19
|
+
# +Opencdd::PropertyIds::REGISTRY+ when the +property_id+ is known
|
|
20
|
+
# there — the REGISTRY is the SSOT for wire-format metadata,
|
|
21
|
+
# and re-declaring it here would violate DRY.
|
|
22
|
+
#
|
|
23
|
+
# Pass an explicit +value_kind+ to override (used when REGISTRY's
|
|
24
|
+
# kind doesn't match how the field is consumed — e.g. MDC_P023
|
|
25
|
+
# is :identifier_ref in REGISTRY but Unit#structure uses it as
|
|
26
|
+
# a raw string).
|
|
27
|
+
#
|
|
28
|
+
# +synthetic: true+ for computed fields with no MDC_P### —
|
|
29
|
+
# requires a block (preferred) that returns the value. The
|
|
30
|
+
# block is evaluated via +instance_exec+ on the entity, so it
|
|
31
|
+
# has access to private helpers without +send+ dispatch.
|
|
32
|
+
def field(name, property_id = nil, value_kind = nil,
|
|
33
|
+
multilingual: nil, synthetic: false, reader: nil,
|
|
34
|
+
as: nil, &block)
|
|
35
|
+
resolved_kind, resolved_ml = resolve_from_registry(property_id)
|
|
36
|
+
Opencdd::Entity::FieldRegistry.register(
|
|
37
|
+
entity_class: self,
|
|
38
|
+
name: name,
|
|
39
|
+
property_id: property_id,
|
|
40
|
+
value_kind: value_kind || resolved_kind || :string,
|
|
41
|
+
multilingual: multilingual.nil? ? resolved_ml : multilingual,
|
|
42
|
+
synthetic: synthetic,
|
|
43
|
+
reader: reader,
|
|
44
|
+
block: block,
|
|
45
|
+
json_key: as,
|
|
46
|
+
)
|
|
47
|
+
define_method(name) do |lang = nil|
|
|
48
|
+
Opencdd::Entity::FieldReader.read(self, name, lang: lang)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def resolve_from_registry(property_id)
|
|
55
|
+
return [nil, false] unless property_id
|
|
56
|
+
entry = Opencdd::PropertyIds::REGISTRY[property_id.to_s]
|
|
57
|
+
return [nil, false] unless entry
|
|
58
|
+
[entry.value_kind, entry.multilingual]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
META_CLASS_CODE = nil
|
|
63
|
+
|
|
64
|
+
attr_reader :irdi, :properties, :schema, :meta_class_irdi, :source_location
|
|
65
|
+
|
|
66
|
+
def self.from_row(row, schema:, meta_class_irdi:, code_property_id: nil)
|
|
67
|
+
code_property_id ||= default_code_property_id(meta_class_irdi)
|
|
68
|
+
raw_code = code_property_id && row[code_property_id]
|
|
69
|
+
irdi = raw_code && Opencdd::IRDI.parse(raw_code)
|
|
70
|
+
|
|
71
|
+
props = row.each_with_object({}) do |(k, v), h|
|
|
72
|
+
next if k == "__row_index__"
|
|
73
|
+
h[k] = v if v
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
new(irdi: irdi, properties: props, schema: schema, meta_class_irdi: meta_class_irdi)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.default_code_property_id(meta_class_irdi)
|
|
80
|
+
Opencdd::MetaClasses.code_property_id_for(meta_class_irdi&.code)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def initialize(irdi:, properties:, schema: nil, meta_class_irdi: nil)
|
|
84
|
+
@irdi = irdi
|
|
85
|
+
@properties = properties
|
|
86
|
+
@schema = schema
|
|
87
|
+
@meta_class_irdi = meta_class_irdi
|
|
88
|
+
@version_history = Opencdd::Entity::VersionHistory.new
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def type
|
|
92
|
+
Opencdd::Parcel::META_CLASS_TYPES[meta_class_irdi&.code]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def code
|
|
96
|
+
@irdi&.code
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
alias_method :short, :code
|
|
100
|
+
|
|
101
|
+
# ── Pure field reads (value_kind and multilingual auto-resolved
|
|
102
|
+
# from PropertyIds::REGISTRY). `as:` aliases preserve the
|
|
103
|
+
# existing JSON wire shape — ruby method names differ from
|
|
104
|
+
# historical JSON keys for backward compatibility. ─────────
|
|
105
|
+
# Note: irdi and code are NOT declared as DSL fields — they're
|
|
106
|
+
# emitted explicitly by Exporters::Json#entity_payload because
|
|
107
|
+
# the model's `irdi` accessor must return the Opencdd::IRDI object
|
|
108
|
+
# (not a String); DSL emission would override it.
|
|
109
|
+
field :version, "MDC_P002_1"
|
|
110
|
+
field :revision, "MDC_P002_2"
|
|
111
|
+
field :preferred_name, "MDC_P004" # multilingual via REGISTRY
|
|
112
|
+
field :short_name, "MDC_P005" # multilingual via REGISTRY
|
|
113
|
+
field :definition, "MDC_P006" # multilingual via REGISTRY
|
|
114
|
+
# source_document wins the MDC_P006_1 dedup (declared before the
|
|
115
|
+
# _of_definition alias); JSON key is "source_document".
|
|
116
|
+
field :source_document, "MDC_P006_1"
|
|
117
|
+
field :source_document_of_definition, "MDC_P006_1"
|
|
118
|
+
field :synonyms, "MDC_P007", :synonym_pairs, multilingual: true, as: "synonyms"
|
|
119
|
+
field :synonymous_names, "MDC_P007", :synonym_pairs, multilingual: true
|
|
120
|
+
field :note, "MDC_P008" # multilingual via REGISTRY
|
|
121
|
+
field :simplified_drawing, "MDC_P008_1", :string
|
|
122
|
+
field :remark, "MDC_P009" # multilingual via REGISTRY
|
|
123
|
+
field :description, "MDC_P112" # multilingual via REGISTRY
|
|
124
|
+
field :example, "MDC_P113"
|
|
125
|
+
field :guid, "MDC_P066", as: "guid"
|
|
126
|
+
field :data_object_identifier, "MDC_P066"
|
|
127
|
+
field :time_stamp, "MDC_P067"
|
|
128
|
+
|
|
129
|
+
# ── C### workbook-specific codes. These are IEC CDD export column
|
|
130
|
+
# identifiers that don't have MDC_P### codes in PropertyIds::REGISTRY.
|
|
131
|
+
# They carry publisher, status, committee, and change-request data.
|
|
132
|
+
# Declared as synthetic (no MDC_P###) with block-form readers
|
|
133
|
+
# evaluated via instance_exec (no send-to-private bypass).
|
|
134
|
+
field(:status_level, synthetic: true) { @properties["C016"] }
|
|
135
|
+
field(:publisher, synthetic: true) { @properties["C011"] }
|
|
136
|
+
field(:published_in, synthetic: true) { @properties["C012"] }
|
|
137
|
+
field(:responsible_committee, synthetic: true) { @properties["MDC_P012"] || @properties["C019.en"] }
|
|
138
|
+
field(:change_request_id, synthetic: true) { @properties["C002"] }
|
|
139
|
+
|
|
140
|
+
# ── Full raw properties dump. Every key in @properties appears
|
|
141
|
+
# in the JSON output. This guarantees "full import" — every
|
|
142
|
+
# column from the .xls is preserved, including multilingual
|
|
143
|
+
# variants (MDC_P004.en/de/fr/zh), C### workbook-specific
|
|
144
|
+
# codes (status, publisher, etc.), and sub-IDs the DSL
|
|
145
|
+
# hasn't named yet. The browser can render typed fields
|
|
146
|
+
# nicely and fall back to raw_properties for completeness.
|
|
147
|
+
field(:raw_properties, synthetic: true) { @properties }
|
|
148
|
+
|
|
149
|
+
# ── Per-version provenance from _entity.json#versions. Set by
|
|
150
|
+
# Opencdd::Parcel::ShardedDirReader after entity creation. The
|
|
151
|
+
# DSL serializer converts VersionHistory → array of entry
|
|
152
|
+
# hashes for JSON emission.
|
|
153
|
+
field(:version_history, synthetic: true) { @version_history }
|
|
154
|
+
|
|
155
|
+
# ── Computed field with custom block ────────────────────────
|
|
156
|
+
Dates = Struct.new(:original_definition, :current_version, :current_revision, keyword_init: true)
|
|
157
|
+
|
|
158
|
+
field(:dates, synthetic: true) do
|
|
159
|
+
Dates.new(
|
|
160
|
+
original_definition: @properties[Opencdd::PropertyIds::MDC_P003_1],
|
|
161
|
+
current_version: @properties[Opencdd::PropertyIds::MDC_P003_2],
|
|
162
|
+
current_revision: @properties[Opencdd::PropertyIds::MDC_P003_3],
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def [](key)
|
|
167
|
+
@properties[key.to_s]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def key?(key)
|
|
171
|
+
@properties.key?(key.to_s)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def keys
|
|
175
|
+
@properties.keys
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def each_property
|
|
179
|
+
return enum_for(:each_property) unless block_given?
|
|
180
|
+
@properties.each { |k, v| yield k, v }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def eql?(other)
|
|
184
|
+
other.is_a?(Opencdd::Entity) &&
|
|
185
|
+
irdi == other.irdi &&
|
|
186
|
+
type == other.type
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def hash
|
|
190
|
+
[irdi, type].hash
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
alias_method :==, :eql?
|
|
194
|
+
|
|
195
|
+
def inspect
|
|
196
|
+
"#<#{self.class.name} #{irdi}>"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def replace_irdi!(new_irdi)
|
|
200
|
+
@irdi = Opencdd::IRDI === new_irdi ? new_irdi : Opencdd::IRDI.parse(new_irdi.to_s)
|
|
201
|
+
self
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def replace_code_value!(code_property_id, new_code)
|
|
205
|
+
return self unless code_property_id
|
|
206
|
+
@properties[code_property_id.to_s] = new_code.to_s
|
|
207
|
+
self
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# ── Field DSL access seam ───────────────────────────────────
|
|
211
|
+
#
|
|
212
|
+
# The canonical way to read or write a typed field on an entity.
|
|
213
|
+
# Goes through FieldRegistry so multilingual, value-kind, and
|
|
214
|
+
# synthetic-field semantics are consistent across all callers
|
|
215
|
+
# (SheetEmitter, Json exporter, validator, GUID setter, etc.).
|
|
216
|
+
# Callers that index +properties+ directly bypass these and
|
|
217
|
+
# risk round-trip drift — see TODO.impl/26.
|
|
218
|
+
|
|
219
|
+
# Read a declared field by name. Accepts an optional +lang:+ for
|
|
220
|
+
# multilingual fields. Returns the typed value (parsed IRDI,
|
|
221
|
+
# Array, etc.) per the field's declared value_kind.
|
|
222
|
+
def read_field(name, lang: nil)
|
|
223
|
+
Opencdd::Entity::FieldReader.read(self, name, lang: lang)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Write a value to a property ID, routing through canonical-id
|
|
227
|
+
# alias resolution so writes are consistent with reads. Use
|
|
228
|
+
# this in preference to +entity.properties[id] = value+ from
|
|
229
|
+
# non-infrastructure callers.
|
|
230
|
+
def write_property!(id, value)
|
|
231
|
+
canonical = Opencdd::PropertyIds.canonical_id(id.to_s) || id.to_s
|
|
232
|
+
base = canonical.to_s.split(".").first
|
|
233
|
+
@properties[base] = value.to_s
|
|
234
|
+
self
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Attach per-version provenance captured in _entity.json. Called
|
|
238
|
+
# by Opencdd::Parcel::ShardedDirReader after entity creation — the
|
|
239
|
+
# constructor doesn't take it because FlatDirReader creates
|
|
240
|
+
# entities from .xls rows without version context.
|
|
241
|
+
def attach_version_history(version_history)
|
|
242
|
+
@version_history = version_history
|
|
243
|
+
self
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Attach source location (file:line) where this entity was
|
|
247
|
+
# declared. Set by Opencdd::Cddal::Builder from import/instantiation
|
|
248
|
+
# context. Nil for entities constructed from Parcel readers
|
|
249
|
+
# (which don't have a single source file).
|
|
250
|
+
def attach_source_location(loc)
|
|
251
|
+
@source_location = loc
|
|
252
|
+
self
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|