opencdd 0.1.1 → 0.2.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 +4 -4
- data/CLAUDE.md +12 -0
- data/lib/opencdd/cddal/serializer.rb +12 -0
- data/lib/opencdd/database/finalization.rb +113 -0
- data/lib/opencdd/database/mutations.rb +129 -0
- data/lib/opencdd/database/parcel_integration.rb +143 -0
- data/lib/opencdd/database/persistence.rb +30 -0
- data/lib/opencdd/database/queries.rb +171 -0
- data/lib/opencdd/database.rb +25 -594
- data/lib/opencdd/{model/yaml_entity.rb → entity/yaml.rb} +121 -50
- data/lib/opencdd/entity.rb +30 -7
- data/lib/opencdd/entity_diff.rb +108 -0
- data/lib/opencdd/exporters/json.rb +30 -1
- data/lib/opencdd/model/entity_store.rb +38 -40
- data/lib/opencdd/model/yaml_database.rb +3 -5
- data/lib/opencdd/model.rb +4 -5
- data/lib/opencdd/parcel/versioned_reader.rb +99 -0
- data/lib/opencdd/parcel.rb +1 -0
- data/lib/opencdd/validator/runner.rb +1 -1
- data/lib/opencdd/version.rb +1 -1
- data/lib/opencdd.rb +1 -0
- metadata +14 -4
|
@@ -1,16 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "lutaml/model"
|
|
4
4
|
|
|
5
5
|
module Opencdd
|
|
6
|
-
|
|
7
|
-
# CDD-native YAML
|
|
8
|
-
# from the CDD ontology (preferred_name, superclass, class_type)
|
|
9
|
-
# rather than the IEC 62656-1 wire-format key (MDC_P004, MDC_P010,
|
|
10
|
-
# MDC_P011). Multilingual fields are Hash<String, String>;
|
|
11
|
-
# collection fields are Array<String>.
|
|
6
|
+
class Entity < Lutaml::Model::Serializable
|
|
7
|
+
# CDD-native YAML serialization model.
|
|
12
8
|
#
|
|
13
|
-
# This is the
|
|
9
|
+
# This is the deepened YAML adapter: the conversion logic that
|
|
10
|
+
# translates between semantic CDD attribute names (preferred_name,
|
|
11
|
+
# superclass, class_type) and IEC 62656-1 wire-format keys
|
|
12
|
+
# (MDC_P004, MDC_P010, MDC_P011) lives here, inside Entity's
|
|
13
|
+
# namespace. Entity delegates +to_yaml+ / +from_yaml+ to this
|
|
14
|
+
# class.
|
|
15
|
+
#
|
|
16
|
+
# Why a separate class? lutaml-model serialization calls
|
|
17
|
+
# +public_send(attr_name)+ during +to_format+, which invokes the
|
|
18
|
+
# getter method. Entity's field DSL getters read from +@properties+
|
|
19
|
+
# and return domain objects (IRDI, source-language String, parsed
|
|
20
|
+
# Array). The YAML attrs need flat types (String, Hash, Array).
|
|
21
|
+
# Sharing the same name would make the getter return the wrong
|
|
22
|
+
# type for serialization. Keeping the YAML model here avoids that
|
|
23
|
+
# conflict while keeping the adapter "inside" Entity.
|
|
24
|
+
#
|
|
25
|
+
# Canonical YAML shape:
|
|
14
26
|
#
|
|
15
27
|
# ---
|
|
16
28
|
# irdi: 0112/2///61360_4#AAA001
|
|
@@ -20,15 +32,12 @@ module Opencdd
|
|
|
20
32
|
# en: Vehicle
|
|
21
33
|
# fr: Véhicule
|
|
22
34
|
# class_type: ITEM_CLASS
|
|
23
|
-
# superclass:
|
|
35
|
+
# superclass: 0112/2///61360_4#AAA000
|
|
24
36
|
# applicable_properties:
|
|
25
|
-
# -
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
# is via #from_entity and #to_entity, which bridge the semantic
|
|
30
|
-
# attributes to the raw properties Hash.
|
|
31
|
-
class YamlEntity < Lutaml::Model::Serializable
|
|
37
|
+
# - 0112/2///61360_4#AAAP001
|
|
38
|
+
# extra:
|
|
39
|
+
# C016: released
|
|
40
|
+
class Yaml < Lutaml::Model::Serializable
|
|
32
41
|
# ── Identity ────────────────────────────────────────────
|
|
33
42
|
attribute :irdi, :string
|
|
34
43
|
attribute :type, :string # :class, :property, :unit, ...
|
|
@@ -41,6 +50,7 @@ module Opencdd
|
|
|
41
50
|
attribute :definition, :hash
|
|
42
51
|
attribute :note, :hash
|
|
43
52
|
attribute :remark, :hash
|
|
53
|
+
attribute :description, :hash
|
|
44
54
|
|
|
45
55
|
# ── Version ─────────────────────────────────────────────
|
|
46
56
|
attribute :version, :string
|
|
@@ -67,18 +77,26 @@ module Opencdd
|
|
|
67
77
|
attribute :unit_text, :string
|
|
68
78
|
|
|
69
79
|
# ── Value-list-specific ─────────────────────────────────
|
|
70
|
-
attribute :enumerated_values, :string, collection: true
|
|
71
80
|
attribute :list_type, :string
|
|
81
|
+
attribute :code_list, :string, collection: true
|
|
82
|
+
attribute :term_irdis, :string, collection: true
|
|
83
|
+
|
|
84
|
+
# ── Value-term-specific ─────────────────────────────────
|
|
85
|
+
attribute :enumeration_code, :string
|
|
72
86
|
|
|
73
87
|
# ── Relation-specific ───────────────────────────────────
|
|
74
88
|
attribute :relation_type, :string
|
|
75
|
-
attribute :domain, :string, collection: true
|
|
76
89
|
attribute :codomain, :string
|
|
90
|
+
attribute :formula, :string
|
|
91
|
+
|
|
92
|
+
# ── View-control-specific ───────────────────────────────
|
|
93
|
+
attribute :controlled_classes, :string, collection: true
|
|
94
|
+
attribute :shown_properties, :string, collection: true
|
|
77
95
|
|
|
78
96
|
# ── Catch-all for unknown properties (lossless) ────────
|
|
79
97
|
attribute :extra, :hash
|
|
80
98
|
|
|
81
|
-
# ── Conversion: Entity →
|
|
99
|
+
# ── Conversion: Entity → Entity::Yaml ───────────────────
|
|
82
100
|
def self.from_entity(entity)
|
|
83
101
|
attrs = {
|
|
84
102
|
irdi: entity.irdi&.to_s,
|
|
@@ -89,64 +107,74 @@ module Opencdd
|
|
|
89
107
|
revision: entity[Opencdd::PropertyIds::MDC_P002_2],
|
|
90
108
|
}
|
|
91
109
|
|
|
92
|
-
# Multilingual fields
|
|
93
110
|
attrs[:preferred_name] = extract_ml(entity, Opencdd::PropertyIds::MDC_P004)
|
|
94
111
|
attrs[:short_name] = extract_ml(entity, Opencdd::PropertyIds::MDC_P005)
|
|
95
112
|
attrs[:definition] = extract_ml(entity, Opencdd::PropertyIds::MDC_P006)
|
|
96
113
|
attrs[:note] = extract_ml(entity, Opencdd::PropertyIds::MDC_P008)
|
|
97
114
|
attrs[:remark] = extract_ml(entity, Opencdd::PropertyIds::MDC_P009)
|
|
115
|
+
attrs[:description] = extract_ml(entity, Opencdd::PropertyIds::MDC_P112)
|
|
98
116
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
attrs[:class_type] = ct&.to_s
|
|
117
|
+
case entity.type
|
|
118
|
+
when :class
|
|
119
|
+
attrs[:class_type] = entity.read_field(:class_type)&.to_s
|
|
103
120
|
attrs[:superclass] = entity.read_field(:superclass_irdi)&.to_s
|
|
104
121
|
attrs[:is_case_of] = entity.read_field(:is_case_of_irdis)&.map(&:to_s) || []
|
|
105
122
|
attrs[:applicable_properties] = entity.read_field(:applicable_property_irdis)&.map(&:to_s) || []
|
|
106
123
|
attrs[:imported_properties] = entity.read_field(:imported_property_irdis)&.map(&:to_s) || []
|
|
107
124
|
attrs[:sub_class_selection] = entity.read_field(:sub_class_selection_irdis)&.map(&:to_s) || []
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
# Property-specific
|
|
111
|
-
if entity.type == :property
|
|
112
|
-
dt = entity.read_field(:parsed_data_type)
|
|
113
|
-
attrs[:data_type] = dt&.to_s
|
|
125
|
+
when :property
|
|
126
|
+
attrs[:data_type] = entity.read_field(:parsed_data_type)&.to_s
|
|
114
127
|
attrs[:value_format] = entity.read_field(:parsed_value_format)&.to_s
|
|
115
|
-
|
|
116
|
-
attrs[:
|
|
117
|
-
ui = entity.read_field(:unit_irdi)
|
|
118
|
-
attrs[:unit] = ui&.to_s
|
|
128
|
+
attrs[:definition_class] = entity.read_field(:definition_class_irdi)&.to_s
|
|
129
|
+
attrs[:unit] = entity.read_field(:unit_irdi)&.to_s
|
|
119
130
|
attrs[:condition] = entity.read_field(:condition)&.to_s
|
|
120
131
|
attrs[:property_data_element_type] = entity.read_field(:property_data_element_type)&.to_s
|
|
132
|
+
when :unit
|
|
133
|
+
attrs[:unit_structure] = entity.read_field(:structure)
|
|
134
|
+
attrs[:unit_text] = entity.read_field(:text_representation)
|
|
135
|
+
when :value_list
|
|
136
|
+
attrs[:list_type] = entity.read_field(:list_type)&.to_s
|
|
137
|
+
attrs[:code_list] = entity.read_field(:code_list) || []
|
|
138
|
+
attrs[:term_irdis] = entity.read_field(:term_irdis)&.map(&:to_s) || []
|
|
139
|
+
when :value_term
|
|
140
|
+
attrs[:enumeration_code] = entity.read_field(:enumeration_code)
|
|
141
|
+
when :relation
|
|
142
|
+
attrs[:relation_type] = entity.read_field(:relation_type)&.to_s
|
|
143
|
+
attrs[:codomain] = entity.read_field(:codomain_irdi)&.to_s
|
|
144
|
+
attrs[:formula] = entity.read_field(:formula)
|
|
145
|
+
when :view_control
|
|
146
|
+
attrs[:controlled_classes] = entity.read_field(:controlled_class_irdis)&.map(&:to_s) || []
|
|
147
|
+
attrs[:shown_properties] = entity.read_field(:shown_property_irdis)&.map(&:to_s) || []
|
|
121
148
|
end
|
|
122
149
|
|
|
150
|
+
extra = extract_extra(entity)
|
|
151
|
+
attrs[:extra] = extra if extra
|
|
152
|
+
|
|
123
153
|
new(**attrs.compact)
|
|
124
154
|
end
|
|
125
155
|
|
|
126
|
-
# ── Conversion:
|
|
127
|
-
def to_entity(
|
|
156
|
+
# ── Conversion: Entity::Yaml → Entity ───────────────────
|
|
157
|
+
def to_entity(_database = nil)
|
|
128
158
|
props = {}
|
|
129
159
|
|
|
130
160
|
props[Opencdd::PropertyIds::MDC_P066] = guid if guid
|
|
131
161
|
props[Opencdd::PropertyIds::MDC_P002_1] = version if version
|
|
132
162
|
props[Opencdd::PropertyIds::MDC_P002_2] = revision if revision
|
|
133
163
|
|
|
134
|
-
# Multilingual
|
|
135
164
|
merge_ml_into(props, Opencdd::PropertyIds::MDC_P004, preferred_name)
|
|
136
165
|
merge_ml_into(props, Opencdd::PropertyIds::MDC_P005, short_name)
|
|
137
166
|
merge_ml_into(props, Opencdd::PropertyIds::MDC_P006, definition)
|
|
138
167
|
merge_ml_into(props, Opencdd::PropertyIds::MDC_P008, note)
|
|
139
168
|
merge_ml_into(props, Opencdd::PropertyIds::MDC_P009, remark)
|
|
169
|
+
merge_ml_into(props, Opencdd::PropertyIds::MDC_P112, description)
|
|
140
170
|
|
|
141
|
-
# Class-specific
|
|
142
171
|
props[Opencdd::PropertyIds::MDC_P011] = class_type if class_type
|
|
143
172
|
props[Opencdd::PropertyIds::MDC_P010] = superclass if superclass
|
|
144
|
-
props[Opencdd::PropertyIds::MDC_P013] =
|
|
145
|
-
props[Opencdd::PropertyIds::MDC_P014] =
|
|
146
|
-
props[Opencdd::PropertyIds::MDC_P090] =
|
|
147
|
-
props[Opencdd::PropertyIds::MDC_P016] =
|
|
173
|
+
props[Opencdd::PropertyIds::MDC_P013] = rejoin_set(is_case_of) if is_case_of&.any?
|
|
174
|
+
props[Opencdd::PropertyIds::MDC_P014] = rejoin_set(applicable_properties) if applicable_properties&.any?
|
|
175
|
+
props[Opencdd::PropertyIds::MDC_P090] = rejoin_set(imported_properties) if imported_properties&.any?
|
|
176
|
+
props[Opencdd::PropertyIds::MDC_P016] = rejoin_set(sub_class_selection) if sub_class_selection&.any?
|
|
148
177
|
|
|
149
|
-
# Property-specific
|
|
150
178
|
props[Opencdd::PropertyIds::MDC_P022] = data_type if data_type
|
|
151
179
|
props[Opencdd::PropertyIds::MDC_P024] = value_format if value_format
|
|
152
180
|
props[Opencdd::PropertyIds::MDC_P021] = definition_class if definition_class
|
|
@@ -154,17 +182,31 @@ module Opencdd
|
|
|
154
182
|
props[Opencdd::PropertyIds::MDC_P028] = condition if condition
|
|
155
183
|
props[Opencdd::PropertyIds::MDC_P020] = property_data_element_type if property_data_element_type
|
|
156
184
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
185
|
+
props[Opencdd::PropertyIds::MDC_P023] = unit_structure if unit_structure
|
|
186
|
+
props[Opencdd::PropertyIds::MDC_P023_1] = unit_text if unit_text
|
|
187
|
+
|
|
188
|
+
props[Opencdd::PropertyIds::MDC_P046] = list_type if list_type
|
|
189
|
+
props[Opencdd::PropertyIds::MDC_P044] = rejoin_set(code_list) if code_list&.any?
|
|
190
|
+
props[Opencdd::PropertyIds::MDC_P043] = rejoin_set(term_irdis) if term_irdis&.any?
|
|
191
|
+
|
|
192
|
+
props[Opencdd::PropertyIds::MDC_P044] = enumeration_code if enumeration_code
|
|
160
193
|
|
|
161
|
-
|
|
162
|
-
|
|
194
|
+
props[Opencdd::PropertyIds::MDC_P200] = relation_type if relation_type
|
|
195
|
+
props[Opencdd::PropertyIds::MDC_P203] = codomain if codomain
|
|
196
|
+
props[Opencdd::PropertyIds::MDC_P204] = formula if formula
|
|
197
|
+
|
|
198
|
+
props[Opencdd::PropertyIds::EXT_P002] = rejoin_set(controlled_classes) if controlled_classes&.any?
|
|
199
|
+
props[Opencdd::PropertyIds::EXT_P003] = rejoin_set(shown_properties) if shown_properties&.any?
|
|
200
|
+
|
|
201
|
+
extra&.each { |k, v| props[k.to_s] = v }
|
|
202
|
+
|
|
203
|
+
entity_class = Opencdd::MetaClasses.entity_class_for_type(type&.to_sym) || Opencdd::Klass
|
|
204
|
+
meta_code = Opencdd::MetaClasses.meta_class_for_type(type&.to_sym) || Opencdd::MetaClasses::MDC_C002
|
|
163
205
|
|
|
164
206
|
entity_class.new(
|
|
165
|
-
irdi:
|
|
207
|
+
irdi: irdi ? Opencdd::IRDI.parse(irdi) : nil,
|
|
166
208
|
properties: props,
|
|
167
|
-
meta_class_irdi:
|
|
209
|
+
meta_class_irdi: Opencdd::IRDI.parse(meta_code),
|
|
168
210
|
)
|
|
169
211
|
end
|
|
170
212
|
|
|
@@ -181,6 +223,31 @@ module Opencdd
|
|
|
181
223
|
end
|
|
182
224
|
private_class_method :extract_ml
|
|
183
225
|
|
|
226
|
+
KNOWN_WIRE_IDS = %w[
|
|
227
|
+
MDC_P002_1 MDC_P002_2 MDC_P066
|
|
228
|
+
MDC_P004 MDC_P005 MDC_P006 MDC_P008 MDC_P009 MDC_P112
|
|
229
|
+
MDC_P011 MDC_P010 MDC_P010_1
|
|
230
|
+
MDC_P013 MDC_P014 MDC_P090 MDC_P016
|
|
231
|
+
MDC_P022 MDC_P024 MDC_P021 MDC_P041 MDC_P028 MDC_P020
|
|
232
|
+
MDC_P023 MDC_P023_1
|
|
233
|
+
MDC_P046 MDC_P044 MDC_P043
|
|
234
|
+
MDC_P200 MDC_P203 MDC_P204
|
|
235
|
+
EXT_P002 EXT_P003
|
|
236
|
+
].freeze
|
|
237
|
+
|
|
238
|
+
def self.extract_extra(entity)
|
|
239
|
+
extra = {}
|
|
240
|
+
entity.properties.each do |k, v|
|
|
241
|
+
key = k.to_s
|
|
242
|
+
next if key == "__row_index__"
|
|
243
|
+
base = key.split(".", 2).first
|
|
244
|
+
next if KNOWN_WIRE_IDS.include?(base)
|
|
245
|
+
extra[key] = v
|
|
246
|
+
end
|
|
247
|
+
extra.empty? ? nil : extra
|
|
248
|
+
end
|
|
249
|
+
private_class_method :extract_extra
|
|
250
|
+
|
|
184
251
|
def self.merge_ml(props, pid, hash)
|
|
185
252
|
return unless hash&.any?
|
|
186
253
|
hash.each { |lang, val| props["#{pid}.#{lang}"] = val }
|
|
@@ -191,6 +258,10 @@ module Opencdd
|
|
|
191
258
|
def merge_ml_into(props, pid, hash)
|
|
192
259
|
self.class.merge_ml(props, pid, hash)
|
|
193
260
|
end
|
|
261
|
+
|
|
262
|
+
def rejoin_set(list)
|
|
263
|
+
Opencdd::StructuredValues.rejoin(list)
|
|
264
|
+
end
|
|
194
265
|
end
|
|
195
266
|
end
|
|
196
267
|
end
|
data/lib/opencdd/entity.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
3
5
|
module Opencdd
|
|
4
|
-
class Entity
|
|
6
|
+
class Entity < Lutaml::Model::Serializable
|
|
5
7
|
include Opencdd::ParseHelpers
|
|
6
8
|
|
|
7
9
|
# Open/closed field declaration. Each `field :foo, "MDC_P###",
|
|
@@ -12,6 +14,7 @@ module Opencdd
|
|
|
12
14
|
autoload :FieldRegistry, "opencdd/entity/field_registry"
|
|
13
15
|
autoload :FieldReader, "opencdd/entity/field_reader"
|
|
14
16
|
autoload :VersionHistory, "opencdd/entity/version_history"
|
|
17
|
+
autoload :Yaml, "opencdd/entity/yaml"
|
|
15
18
|
|
|
16
19
|
class << self
|
|
17
20
|
# Declare a typed field on this entity class. Defaults for
|
|
@@ -80,9 +83,10 @@ module Opencdd
|
|
|
80
83
|
Opencdd::MetaClasses.code_property_id_for(meta_class_irdi&.code)
|
|
81
84
|
end
|
|
82
85
|
|
|
83
|
-
def initialize(irdi
|
|
86
|
+
def initialize(irdi: nil, properties: nil, schema: nil, meta_class_irdi: nil)
|
|
87
|
+
super({})
|
|
84
88
|
@irdi = irdi
|
|
85
|
-
@properties = properties
|
|
89
|
+
@properties = properties || {}
|
|
86
90
|
@schema = schema
|
|
87
91
|
@meta_class_irdi = meta_class_irdi
|
|
88
92
|
@version_history = Opencdd::Entity::VersionHistory.new
|
|
@@ -98,10 +102,6 @@ module Opencdd
|
|
|
98
102
|
|
|
99
103
|
alias_method :short, :code
|
|
100
104
|
|
|
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
105
|
# Note: irdi and code are NOT declared as DSL fields — they're
|
|
106
106
|
# emitted explicitly by Exporters::Json#entity_payload because
|
|
107
107
|
# the model's `irdi` accessor must return the Opencdd::IRDI object
|
|
@@ -251,5 +251,28 @@ module Opencdd
|
|
|
251
251
|
@source_location = loc
|
|
252
252
|
self
|
|
253
253
|
end
|
|
254
|
+
|
|
255
|
+
# ── YAML persistence via Entity::Yaml ──────────────────────
|
|
256
|
+
# Entity extends Lutaml::Model::Serializable but its own attribute
|
|
257
|
+
# set is empty — the typed YAML model lives in Entity::Yaml
|
|
258
|
+
# (the deepened adapter). This avoids name conflicts between the
|
|
259
|
+
# field DSL getters (which read from @properties and return
|
|
260
|
+
# coerced values like IRDI objects, source-language Strings,
|
|
261
|
+
# parsed Arrays) and lutaml-model serialization attrs (which
|
|
262
|
+
# must return flat types: String, Hash, Array).
|
|
263
|
+
#
|
|
264
|
+
# lutaml-model serialization calls +public_send(attr_name)+
|
|
265
|
+
# during to_format, so any getter with the same name as a
|
|
266
|
+
# YAML attr is invoked. Keeping the YAML model in Entity::Yaml
|
|
267
|
+
# lets both worlds coexist: field DSL for domain access,
|
|
268
|
+
# Entity::Yaml for serialization.
|
|
269
|
+
|
|
270
|
+
def to_yaml(*args)
|
|
271
|
+
Opencdd::Entity::Yaml.from_entity(self).to_yaml(*args)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def self.from_yaml(yaml_str)
|
|
275
|
+
Opencdd::Entity::Yaml.from_yaml(yaml_str).to_entity
|
|
276
|
+
end
|
|
254
277
|
end
|
|
255
278
|
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencdd
|
|
4
|
+
# Compute a structured diff between two entities of the same IRDI.
|
|
5
|
+
#
|
|
6
|
+
# Pure value object: doesn't mutate its inputs, doesn't reach
|
|
7
|
+
# beyond +entity.properties+. Iterates the union of keys and
|
|
8
|
+
# categorizes each into +added+, +removed+, or +changed+.
|
|
9
|
+
#
|
|
10
|
+
# Multilingual fields (<tt>MDC_P001.en</tt>, <tt>MDC_P001.fr</tt>)
|
|
11
|
+
# are diffed as a group keyed by their base property ID. A change
|
|
12
|
+
# in any language counts as one change to the base field.
|
|
13
|
+
#
|
|
14
|
+
# Example:
|
|
15
|
+
# diff = Opencdd::EntityDiff.between(v001, v002)
|
|
16
|
+
# diff.added # => ["MDC_P999"]
|
|
17
|
+
# diff.removed # => []
|
|
18
|
+
# diff.changed # => [{ field: "preferred_name", from: "X", to: "Y" }]
|
|
19
|
+
# diff.empty? # => false
|
|
20
|
+
class EntityDiff
|
|
21
|
+
Change = Struct.new(:field, :from, :to, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
attr_reader :from_entity, :to_entity, :added, :removed, :changed
|
|
24
|
+
|
|
25
|
+
def self.between(from_entity, to_entity)
|
|
26
|
+
new(from_entity, to_entity)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(from_entity, to_entity)
|
|
30
|
+
raise ArgumentError, "EntityDiff requires same IRDI (#{from_entity.irdi} vs #{to_entity.irdi})" unless from_entity.irdi == to_entity.irdi
|
|
31
|
+
@from_entity = from_entity
|
|
32
|
+
@to_entity = to_entity
|
|
33
|
+
compute_diff
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def empty?
|
|
37
|
+
added.empty? && removed.empty? && changed.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Total change count (added + removed + changed).
|
|
41
|
+
def size
|
|
42
|
+
added.size + removed.size + changed.size
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Wire-format summary suitable for JSON emit or browser diff view.
|
|
46
|
+
def to_h
|
|
47
|
+
{
|
|
48
|
+
irdi: from_entity.irdi.to_s,
|
|
49
|
+
added: added,
|
|
50
|
+
removed: removed,
|
|
51
|
+
changed: changed.map(&:to_h),
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def compute_diff
|
|
58
|
+
@added = []
|
|
59
|
+
@removed = []
|
|
60
|
+
@changed = []
|
|
61
|
+
from_groups = grouped_properties(from_entity)
|
|
62
|
+
to_groups = grouped_properties(to_entity)
|
|
63
|
+
(from_groups.keys | to_groups.keys).sort.each do |field|
|
|
64
|
+
from_val = from_groups[field]
|
|
65
|
+
to_val = to_groups[field]
|
|
66
|
+
if from_val.nil? && !to_val.nil?
|
|
67
|
+
@added << field
|
|
68
|
+
elsif !from_val.nil? && to_val.nil?
|
|
69
|
+
@removed << field
|
|
70
|
+
elsif values_differ?(from_val, to_val)
|
|
71
|
+
@changed << Change.new(field: field, from: from_val, to: to_val)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Group multilingual keys (<base>.<lang>) under their <base>.
|
|
77
|
+
# Returns +{ base_property_id => grouped_value }+ where
|
|
78
|
+
# +grouped_value+ is a string for monolingual fields or a
|
|
79
|
+
# sorted +{ lang => value }+ hash for multilingual fields.
|
|
80
|
+
def grouped_properties(entity)
|
|
81
|
+
groups = Hash.new { |h, k| h[k] = {} }
|
|
82
|
+
entity.properties.each do |key, value|
|
|
83
|
+
next if value.nil?
|
|
84
|
+
k = key.to_s
|
|
85
|
+
base, lang = split_language_suffix(k)
|
|
86
|
+
if lang
|
|
87
|
+
groups[base][lang] = value.to_s
|
|
88
|
+
else
|
|
89
|
+
groups[base] = value.to_s
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
# Collapse empty hashes (a group that had only nil values).
|
|
93
|
+
groups.transform_values { |v| v.is_a?(Hash) && v.empty? ? nil : v }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def split_language_suffix(key)
|
|
97
|
+
m = key.match(/\A(?<base>.+?)\.(?<lang>[a-z]{2,3}(?:-[a-z0-9]+)?)\z/i)
|
|
98
|
+
return [key, nil] unless m
|
|
99
|
+
[m[:base], m[:lang].downcase]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def values_differ?(from_val, to_val)
|
|
103
|
+
return from_val != to_val unless from_val.is_a?(Hash) && to_val.is_a?(Hash)
|
|
104
|
+
# For multilingual groups: compare as normalized hashes.
|
|
105
|
+
from_val == to_val ? false : true
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -53,7 +53,34 @@ module Opencdd
|
|
|
53
53
|
@nodes << view_control_node(vc)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
# ─────────────────────────────────────────────────────────────
|
|
57
|
+
# Public per-entity payload API
|
|
58
|
+
#
|
|
59
|
+
# `payload_for(entity, database:)` returns the wire-format Hash
|
|
60
|
+
# for a single entity. Dispatch is via the PAYLOAD_BUILDERS
|
|
61
|
+
# registry — adding a new entity type means adding one entry to
|
|
62
|
+
# the registry and one builder method, not editing a switch.
|
|
63
|
+
#
|
|
64
|
+
# The optional `database:` enables cross-entity resolution
|
|
65
|
+
# (e.g. property → value_list). Without it, cross-links are
|
|
66
|
+
# omitted (payload still has all the entity's own fields).
|
|
67
|
+
# ─────────────────────────────────────────────────────────────
|
|
68
|
+
PAYLOAD_BUILDERS = {
|
|
69
|
+
Opencdd::Klass => :class_node,
|
|
70
|
+
Opencdd::Property => :property_node,
|
|
71
|
+
Opencdd::Unit => :unit_node,
|
|
72
|
+
Opencdd::ValueList => :value_list_node,
|
|
73
|
+
Opencdd::ValueTerm => :value_term_node,
|
|
74
|
+
Opencdd::Relation => :relation_node,
|
|
75
|
+
Opencdd::ViewControl => :view_control_node,
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
def payload_for(entity, database: nil)
|
|
79
|
+
@database = database if database
|
|
80
|
+
builder = PAYLOAD_BUILDERS[entity.class]
|
|
81
|
+
raise ArgumentError, "No JSON payload builder for #{entity.class}" unless builder
|
|
82
|
+
public_send(builder, entity)
|
|
83
|
+
end
|
|
57
84
|
|
|
58
85
|
# ─────────────────────────────────────────────────────────────
|
|
59
86
|
# Open/closed payload builders
|
|
@@ -98,6 +125,8 @@ module Opencdd
|
|
|
98
125
|
entity_payload(vc).merge(type: "view_control").compact
|
|
99
126
|
end
|
|
100
127
|
|
|
128
|
+
private
|
|
129
|
+
|
|
101
130
|
# Iterates every declared field on the entity's class (walking
|
|
102
131
|
# the ancestor chain via FieldRegistry.fields_for). Each field's
|
|
103
132
|
# value is read by name (synthetic fields call their custom
|
|
@@ -1,25 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "lutaml/store"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
6
|
module Opencdd
|
|
7
7
|
module Model
|
|
8
|
-
# Per-entity YAML persistence using lutaml-store
|
|
9
|
-
# Each entity is stored as a single YAML file
|
|
10
|
-
# serialized via Lutaml::Model (
|
|
11
|
-
#
|
|
8
|
+
# Per-entity YAML persistence using lutaml-store's DatabaseStore
|
|
9
|
+
# as the backend. Each entity is stored as a single YAML file,
|
|
10
|
+
# serialized via Lutaml::Model (Entity::Yaml — the deepened
|
|
11
|
+
# adapter inside Entity's namespace). Diff-friendly at the entity
|
|
12
|
+
# level — one git diff shows exactly which entity changed.
|
|
12
13
|
#
|
|
13
|
-
#
|
|
14
|
+
# Uses DatabaseStore#save_all and #load_all with format: :yaml
|
|
15
|
+
# and layout: :separate (one file per entity). This is the
|
|
16
|
+
# proper lutaml-store API — not the low-level adapter.set/get
|
|
17
|
+
# bypass that the previous version used.
|
|
18
|
+
#
|
|
19
|
+
# Directory layout (managed by DatabaseStore's separate layout):
|
|
14
20
|
#
|
|
15
21
|
# data/
|
|
16
22
|
# └── entities/
|
|
17
|
-
# ├──
|
|
18
|
-
#
|
|
19
|
-
# │ └── AAA010.data
|
|
23
|
+
# ├── 0112_2___61360_4_AAA001.yaml
|
|
24
|
+
# ├── 0112_2___61360_4_AAA010.yaml
|
|
20
25
|
# └── ...
|
|
21
26
|
#
|
|
22
|
-
# The
|
|
27
|
+
# The filename is the model's key field (irdi), sanitized by
|
|
28
|
+
# the FileSystem adapter (non-alphanumeric chars → underscore).
|
|
23
29
|
class EntityStore
|
|
24
30
|
attr_reader :path, :store
|
|
25
31
|
|
|
@@ -28,23 +34,21 @@ module Opencdd
|
|
|
28
34
|
FileUtils.mkdir_p(@path)
|
|
29
35
|
@store = Lutaml::Store.new(
|
|
30
36
|
adapter: :filesystem,
|
|
31
|
-
adapter_options: { path: @path },
|
|
37
|
+
adapter_options: { path: @path, extension: ".yaml" },
|
|
32
38
|
models: [
|
|
33
|
-
{ model: Opencdd::
|
|
39
|
+
{ model: Opencdd::Entity::Yaml, key: :irdi, dir: "entities" },
|
|
34
40
|
],
|
|
35
41
|
)
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
# Save all entities from +database+ to individual YAML files
|
|
39
|
-
# via
|
|
45
|
+
# via DatabaseStore#save_all. Returns self.
|
|
40
46
|
def save_database(database)
|
|
41
|
-
database.entities.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
next unless key
|
|
45
|
-
yaml_str = yaml_entity.to_yaml
|
|
46
|
-
@store.store.adapter.set(key, yaml_str)
|
|
47
|
+
yaml_entities = database.entities.filter_map do |entity|
|
|
48
|
+
yaml = Opencdd::Entity::Yaml.from_entity(entity)
|
|
49
|
+
yaml.irdi ? yaml : nil
|
|
47
50
|
end
|
|
51
|
+
@store.save_all(yaml_entities, path: @path, format: :yaml, layout: :separate)
|
|
48
52
|
self
|
|
49
53
|
end
|
|
50
54
|
|
|
@@ -52,33 +56,27 @@ module Opencdd
|
|
|
52
56
|
# Returns a finalized Database.
|
|
53
57
|
def load_database(database = nil)
|
|
54
58
|
database ||= Opencdd::Database.new
|
|
55
|
-
@store.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
yaml_entities = @store.load_all(
|
|
60
|
+
Opencdd::Entity::Yaml,
|
|
61
|
+
path: @path,
|
|
62
|
+
format: :yaml,
|
|
63
|
+
layout: :separate,
|
|
64
|
+
)
|
|
65
|
+
yaml_entities.each do |yaml_entity|
|
|
66
|
+
entity = yaml_entity.to_entity(database)
|
|
67
|
+
database.add_entity(entity)
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
warn "EntityStore: skipping entity: #{e.message}"
|
|
65
70
|
end
|
|
66
71
|
database.finalize!
|
|
67
72
|
database
|
|
68
73
|
end
|
|
69
74
|
|
|
70
|
-
# Fetch a single entity by key.
|
|
75
|
+
# Fetch a single entity's YAML model by key.
|
|
71
76
|
def fetch(key)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
private
|
|
78
|
-
|
|
79
|
-
def safe_key(key)
|
|
80
|
-
return nil if key.nil? || key.to_s.strip.empty?
|
|
81
|
-
key.to_s.split("#").last || key.to_s
|
|
77
|
+
@store.fetch(model: Opencdd::Entity::Yaml, irdi: key)
|
|
78
|
+
rescue Lutaml::Store::InvalidKeyError
|
|
79
|
+
nil
|
|
82
80
|
end
|
|
83
81
|
end
|
|
84
82
|
end
|
|
@@ -4,7 +4,7 @@ require "lutaml/model"
|
|
|
4
4
|
|
|
5
5
|
module Opencdd
|
|
6
6
|
module Model
|
|
7
|
-
# CDD-native YAML database. Wraps a collection of
|
|
7
|
+
# CDD-native YAML database. Wraps a collection of Entity::Yaml
|
|
8
8
|
# objects with dictionary-level metadata (source language,
|
|
9
9
|
# translation languages). Serializes to YAML via lutaml-model
|
|
10
10
|
# — no hand-rolled serialization on the model class itself.
|
|
@@ -26,18 +26,16 @@ module Opencdd
|
|
|
26
26
|
class YamlDatabase < Lutaml::Model::Serializable
|
|
27
27
|
attribute :source_language, :string, default: "en"
|
|
28
28
|
attribute :translation_languages, :string, collection: true
|
|
29
|
-
attribute :entities,
|
|
29
|
+
attribute :entities, Opencdd::Entity::Yaml, collection: true
|
|
30
30
|
|
|
31
|
-
# ── Conversion: Database → YamlDatabase ─────────────────
|
|
32
31
|
def self.from_database(database)
|
|
33
32
|
new(
|
|
34
33
|
source_language: "en",
|
|
35
34
|
translation_languages: [],
|
|
36
|
-
entities: database.entities.map { |e|
|
|
35
|
+
entities: database.entities.map { |e| Opencdd::Entity::Yaml.from_entity(e) },
|
|
37
36
|
)
|
|
38
37
|
end
|
|
39
38
|
|
|
40
|
-
# ── Conversion: YamlDatabase → Database ─────────────────
|
|
41
39
|
def to_database(database = nil)
|
|
42
40
|
database ||= Opencdd::Database.new
|
|
43
41
|
entities.each { |ye| database.add_entity(ye.to_entity(database)) }
|
data/lib/opencdd/model.rb
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Opencdd
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
4
|
+
# Database-level YAML persistence helpers. Entity-level YAML
|
|
5
|
+
# serialization lives on +Opencdd::Entity::Yaml+ (the deepened
|
|
6
|
+
# adapter inside Entity's namespace). This module holds the
|
|
7
|
+
# whole-database YAML wrapper and the per-entity file store.
|
|
8
8
|
module Model
|
|
9
|
-
autoload :YamlEntity, "opencdd/model/yaml_entity"
|
|
10
9
|
autoload :YamlDatabase, "opencdd/model/yaml_database"
|
|
11
10
|
autoload :EntityStore, "opencdd/model/entity_store"
|
|
12
11
|
end
|