solis 0.122.0 → 0.124.0
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/lib/solis/model.rb +311 -235
- data/lib/solis/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d5d2032cb4615c4ab9d41f6ff2b89f6000fcc5bfc29ff55972305166b2f7904
|
|
4
|
+
data.tar.gz: 572b1eacf27a9e63579e4403c9f00c003c8dde292b28492960669aa933b8755f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d501867d8e19209212b168abf92b0d91b3b95b183c40732943d8c373afa0d72acbe1c0d6fd23ba6e0ee5c7e8c8aa6de3cbc65ca01975ae1d26f95c6e0afa6bb3
|
|
7
|
+
data.tar.gz: 8642feba84874ac698cbc8d9b3cfacd775f8fc90793fec6092834bd79108ae21862c8a5dbe803bf3f17032983199838619ab10a301953934af1dfd15ce31d171
|
data/lib/solis/model.rb
CHANGED
|
@@ -26,10 +26,18 @@ module Solis
|
|
|
26
26
|
inner_class = self.class.metadata[:attributes][attribute.to_s][:datatype].to_s
|
|
27
27
|
inner_model = self.class.graph.shape_as_model(inner_class)
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
# Resolve a polymorphic reference to its concrete subclass, preferring an
|
|
30
|
+
# explicit `type` key, then a full URI whose path segment names the class.
|
|
31
|
+
# Keys may be strings (JSON) or symbols (internal callers).
|
|
32
|
+
explicit_type = (value['type'] || value[:type] || value['@type'] || value[:'@type']).to_s
|
|
33
|
+
value = value.reject { |k, _| %w[type @type].include?(k.to_s) }
|
|
34
|
+
id_value = (value['id'] || value[:id]).to_s
|
|
35
|
+
if !explicit_type.empty? && descendant_shape_names(inner_model.name).include?(explicit_type)
|
|
36
|
+
inner_model = self.class.graph.shape_as_model(explicit_type)
|
|
37
|
+
elsif !id_value.empty? && id_value.match?(self.class.graph_name)
|
|
38
|
+
concrete = id_value.gsub(self.class.graph_name, '').split('/').first.classify.to_s
|
|
39
|
+
if descendant_shape_names(inner_model.name).include?(concrete)
|
|
40
|
+
inner_model = self.class.graph.shape_as_model(concrete)
|
|
33
41
|
end
|
|
34
42
|
end
|
|
35
43
|
|
|
@@ -87,22 +95,22 @@ module Solis
|
|
|
87
95
|
end
|
|
88
96
|
|
|
89
97
|
def to_ttl(resolve_all = true)
|
|
90
|
-
graph = as_graph(self, resolve_all)
|
|
98
|
+
graph = as_graph(self, deep: resolve_all)
|
|
91
99
|
graph.dump(:ttl)
|
|
92
100
|
end
|
|
93
101
|
|
|
94
102
|
def dump(format = :ttl, resolve_all = true)
|
|
95
|
-
graph = as_graph(self, resolve_all)
|
|
103
|
+
graph = as_graph(self, deep: resolve_all)
|
|
96
104
|
graph.dump(format)
|
|
97
105
|
end
|
|
98
106
|
|
|
99
107
|
def to_graph(resolve_all = true)
|
|
100
|
-
as_graph(self, resolve_all)
|
|
108
|
+
as_graph(self, deep: resolve_all)
|
|
101
109
|
end
|
|
102
110
|
|
|
103
111
|
def valid?
|
|
104
112
|
begin
|
|
105
|
-
graph = as_graph(self
|
|
113
|
+
graph = as_graph(self)
|
|
106
114
|
rescue Solis::Error::InvalidAttributeError => e
|
|
107
115
|
Solis::LOGGER.error(e.message)
|
|
108
116
|
end
|
|
@@ -156,60 +164,53 @@ values ?s {<#{self.graph_id}>}
|
|
|
156
164
|
data = properties_to_hash(self)
|
|
157
165
|
result = update(data, validate_dependencies, top_level, sparql)
|
|
158
166
|
else
|
|
159
|
-
data = properties_to_hash(self)
|
|
160
|
-
attributes = data.include?('attributes') ? data['attributes'] : data
|
|
161
167
|
readonly_list = (Solis::Options.instance.get[:embedded_readonly] || []).map(&:to_s)
|
|
162
168
|
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if readonly_entity?(embedded, readonly_list)
|
|
186
|
-
unless entity_exists
|
|
187
|
-
Solis::LOGGER.warn("#{embedded.class.name} (id: #{embedded.id}) is readonly but does not exist in database. Skipping.")
|
|
188
|
-
end
|
|
169
|
+
# Re-type polymorphic base-class id-only references (e.g. an `agent` stub
|
|
170
|
+
# that is really an `Organisatie`) to their concrete subclass, so URIs and
|
|
171
|
+
# existence checks target the subclass's storage path.
|
|
172
|
+
resolve_polymorphic_references!(self, sparql)
|
|
173
|
+
|
|
174
|
+
# Enumerate the whole in-memory tree: self plus every embedded descendant.
|
|
175
|
+
all_entities = collect_known_entities(self).values
|
|
176
|
+
existing_ids = self.class.batch_exists?(sparql, all_entities)
|
|
177
|
+
|
|
178
|
+
# Classify each entity: new (insert), existing embedded (update), readonly,
|
|
179
|
+
# or a pure reference. readonly only protects EMBEDDED entities; the entity
|
|
180
|
+
# being saved (self) is always created even when its class is a code table.
|
|
181
|
+
new_entities = []
|
|
182
|
+
existing_embedded = []
|
|
183
|
+
all_entities.each do |entity|
|
|
184
|
+
entity_exists = existing_ids.include?(entity.graph_id)
|
|
185
|
+
if !entity.equal?(self) && readonly_entity?(entity, readonly_list)
|
|
186
|
+
Solis::LOGGER.warn("#{entity.class.name} (id: #{entity.id}) is readonly but does not exist in database. Skipping.") unless entity_exists
|
|
187
|
+
elsif !entity.equal?(self) && shallow_stub?(entity) && top_level_entity?(entity)
|
|
188
|
+
# An id-only reference to an independently-addressable entity: link only.
|
|
189
|
+
# It is emitted as a URI by serialize_entity; never create or rewrite it.
|
|
190
|
+
raise Solis::Error::NotFoundError, "#{entity.class.name} (id: #{entity.id}) is referenced but does not exist" unless entity_exists
|
|
189
191
|
elsif entity_exists
|
|
190
|
-
|
|
192
|
+
existing_embedded << entity
|
|
191
193
|
else
|
|
192
|
-
|
|
194
|
+
new_entities << entity
|
|
193
195
|
end
|
|
194
196
|
end
|
|
195
197
|
|
|
196
|
-
#
|
|
197
|
-
unless
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
sparql.insert_data(embedded_graph, graph: embedded_graph.name)
|
|
198
|
+
# Existing embedded entities are updated individually (each needs DELETE/INSERT).
|
|
199
|
+
unless existing_embedded.empty?
|
|
200
|
+
embedded_originals = batch_load_originals(existing_embedded)
|
|
201
|
+
existing_embedded.each do |embedded|
|
|
202
|
+
embedded.update(properties_to_hash(embedded), validate_dependencies, false, nil,
|
|
203
|
+
prefetched_original: embedded_originals[embedded.id])
|
|
204
|
+
end
|
|
204
205
|
end
|
|
205
206
|
|
|
206
|
-
#
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
207
|
+
# Serialize self and every new embedded entity into one INSERT DATA operation.
|
|
208
|
+
graph = RDF::Graph.new
|
|
209
|
+
graph.name = RDF::URI(self.class.graph_name)
|
|
210
|
+
visited = Set.new
|
|
211
|
+
new_entities.each { |entity| serialize_entity(graph, entity, false, visited, []) }
|
|
211
212
|
|
|
212
|
-
graph
|
|
213
|
+
validate_graph(graph) if validate_dependencies
|
|
213
214
|
|
|
214
215
|
Solis::LOGGER.info SPARQL::Client::Update::InsertData.new(graph, graph: graph.name).to_s if ConfigFile[:debug]
|
|
215
216
|
|
|
@@ -250,12 +251,10 @@ values ?s {<#{self.graph_id}>}
|
|
|
250
251
|
id = attributes.delete('id')
|
|
251
252
|
sparql = sparql_client || SPARQL::Client.new(self.class.sparql_endpoint)
|
|
252
253
|
|
|
253
|
-
|
|
254
|
-
#
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
original_klass = nil if original_klass && shallow_stub?(original_klass)
|
|
258
|
-
original_klass ||= self.query.filter({ language: self.class.language, filters: { id: [id] } }).find_all.map { |m| m }&.first
|
|
254
|
+
# prefetched_original is used only when it is a complete entity; an id-only stub
|
|
255
|
+
# cannot seed updated_klass (omitted mandatory attributes would be lost).
|
|
256
|
+
original_klass = prefetched_original unless prefetched_original && shallow_stub?(prefetched_original)
|
|
257
|
+
original_klass ||= load_original(id)
|
|
259
258
|
raise Solis::Error::NotFoundError if original_klass.nil?
|
|
260
259
|
updated_klass = original_klass.deep_dup
|
|
261
260
|
|
|
@@ -267,11 +266,17 @@ values ?s {<#{self.graph_id}>}
|
|
|
267
266
|
|
|
268
267
|
# First pass: collect all embedded entities for batched existence check
|
|
269
268
|
embedded_by_key = {}
|
|
269
|
+
poly_cache = {}
|
|
270
270
|
attributes.each_pair do |key, value|
|
|
271
271
|
unless original_klass.class.metadata[:attributes][key][:node].nil?
|
|
272
272
|
value = [value] unless value.is_a?(Array)
|
|
273
273
|
embedded_by_key[key] = value.map do |sub_value|
|
|
274
|
-
self.class.graph.shape_as_model(original_klass.class.metadata[:attributes][key][:datatype].to_s).new(sub_value)
|
|
274
|
+
model = self.class.graph.shape_as_model(original_klass.class.metadata[:attributes][key][:datatype].to_s).new(sub_value)
|
|
275
|
+
# Re-type a polymorphic base-class id-only reference to its concrete
|
|
276
|
+
# subclass so existence checks and emitted URIs target the right path.
|
|
277
|
+
concrete = resolve_polymorphic_class(model, sparql, poly_cache)
|
|
278
|
+
model = concrete.new({ id: model.id }) if concrete && concrete != model.class
|
|
279
|
+
model
|
|
275
280
|
end
|
|
276
281
|
end
|
|
277
282
|
end
|
|
@@ -279,15 +284,12 @@ values ?s {<#{self.graph_id}>}
|
|
|
279
284
|
all_embedded = embedded_by_key.values.flatten
|
|
280
285
|
existing_ids = self.class.batch_exists?(sparql, all_embedded)
|
|
281
286
|
|
|
282
|
-
#
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
next if orig.nil?
|
|
287
|
-
Array(orig).each do |e|
|
|
288
|
-
original_embedded_lookup[e.id] = e if solis_model?(e) && e.id
|
|
289
|
-
end
|
|
287
|
+
# Batch-load full stored originals for embedded entities that already exist, so
|
|
288
|
+
# each recursive embedded update receives a complete original (one query per class).
|
|
289
|
+
existing_embedded = all_embedded.select do |e|
|
|
290
|
+
existing_ids.include?(e.graph_id) && !readonly_entity?(e, readonly_list)
|
|
290
291
|
end
|
|
292
|
+
embedded_originals = batch_load_originals(existing_embedded)
|
|
291
293
|
|
|
292
294
|
# Second pass: process embedded entities using batched results
|
|
293
295
|
embedded_by_key.each do |key, embedded_list|
|
|
@@ -319,9 +321,7 @@ values ?s {<#{self.graph_id}>}
|
|
|
319
321
|
else
|
|
320
322
|
if entity_exists
|
|
321
323
|
embedded_data = properties_to_hash(embedded)
|
|
322
|
-
|
|
323
|
-
prefetched = original_embedded_lookup[embedded.id]
|
|
324
|
-
embedded.update(embedded_data, validate_dependencies, false, nil, prefetched_original: prefetched)
|
|
324
|
+
embedded.update(embedded_data, validate_dependencies, false, nil, prefetched_original: embedded_originals[embedded.id])
|
|
325
325
|
new_embedded_values << embedded
|
|
326
326
|
else
|
|
327
327
|
embedded_value = embedded.save(validate_dependencies, false)
|
|
@@ -369,10 +369,11 @@ values ?s {<#{self.graph_id}>}
|
|
|
369
369
|
Solis::LOGGER.info("#{original_klass.class.name} unchanged, skipping")
|
|
370
370
|
data = original_klass
|
|
371
371
|
else
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
|
|
375
|
-
delete_graph = as_graph(original_klass, false
|
|
372
|
+
# The delete graph carries the stored original's triples; the insert graph the
|
|
373
|
+
# updated entity's. Embedded children are emitted as URI references in both —
|
|
374
|
+
# they are persisted by their own recursive update/save above.
|
|
375
|
+
delete_graph = as_graph(original_klass, deep: false)
|
|
376
|
+
insert_graph = as_graph(updated_klass, deep: false)
|
|
376
377
|
where_graph = RDF::Graph.new(graph_name: RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), data: RDF::Repository.new)
|
|
377
378
|
|
|
378
379
|
if id.is_a?(Array)
|
|
@@ -383,8 +384,7 @@ values ?s {<#{self.graph_id}>}
|
|
|
383
384
|
where_graph << [RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), :p, :o]
|
|
384
385
|
end
|
|
385
386
|
|
|
386
|
-
|
|
387
|
-
insert_graph = as_graph(updated_klass, true, insert_known)
|
|
387
|
+
validate_graph(insert_graph) if validate_dependencies
|
|
388
388
|
|
|
389
389
|
delete_insert_query = SPARQL::Client::Update::DeleteInsert.new(delete_graph, insert_graph, where_graph, graph: insert_graph.name).to_s
|
|
390
390
|
delete_insert_query.gsub!('_:p', '?p')
|
|
@@ -412,7 +412,7 @@ values ?s {<#{self.graph_id}>}
|
|
|
412
412
|
|
|
413
413
|
data
|
|
414
414
|
rescue StandardError => e
|
|
415
|
-
original_graph = as_graph(original_klass, false) if defined?(original_klass) && original_klass
|
|
415
|
+
original_graph = as_graph(original_klass, deep: false) if defined?(original_klass) && original_klass
|
|
416
416
|
Solis::LOGGER.error(e.message)
|
|
417
417
|
Solis::LOGGER.error original_graph.dump(:ttl) if defined?(original_graph) && original_graph
|
|
418
418
|
Solis::LOGGER.error delete_insert_query if defined?(delete_insert_query)
|
|
@@ -464,14 +464,11 @@ values ?s {<#{self.graph_id}>}
|
|
|
464
464
|
to_create.each_slice(batch_size) do |batch|
|
|
465
465
|
combined_graph = RDF::Graph.new
|
|
466
466
|
combined_graph.name = RDF::URI(graph_name)
|
|
467
|
-
|
|
468
|
-
# Pre-collect known entities from all entities being created
|
|
469
|
-
known = {}
|
|
470
|
-
batch.each { |e| e.send(:collect_known_entities, e, known) }
|
|
467
|
+
visited = Set.new
|
|
471
468
|
|
|
472
469
|
batch.each do |entity|
|
|
473
470
|
entity.before_create_proc&.call(entity)
|
|
474
|
-
entity.send(:
|
|
471
|
+
entity.send(:serialize_entity, combined_graph, entity, true, visited, [])
|
|
475
472
|
end
|
|
476
473
|
|
|
477
474
|
sparql.insert_data(combined_graph, graph: combined_graph.name)
|
|
@@ -654,8 +651,8 @@ values ?s {<#{self.graph_id}>}
|
|
|
654
651
|
|
|
655
652
|
private
|
|
656
653
|
|
|
657
|
-
# Walk the entity tree and collect
|
|
658
|
-
#
|
|
654
|
+
# Walk the in-memory entity tree and collect every entity by UUID
|
|
655
|
+
# ({ uuid => entity }), following embedded (node_kind) attributes.
|
|
659
656
|
def collect_known_entities(entity, collected = {})
|
|
660
657
|
uuid = entity.instance_variable_get("@id")
|
|
661
658
|
return collected if uuid.nil? || collected.key?(uuid)
|
|
@@ -683,6 +680,118 @@ values ?s {<#{self.graph_id}>}
|
|
|
683
680
|
end
|
|
684
681
|
end
|
|
685
682
|
|
|
683
|
+
# Map of shape_name => parent_shape_name, derived from each shape's sh:node
|
|
684
|
+
# (target_node) pointing at "<graph_name><Parent>Shape". Pure metadata.
|
|
685
|
+
def polymorphic_parent_map
|
|
686
|
+
graph_name = self.class.graph_name
|
|
687
|
+
map = {}
|
|
688
|
+
self.class.shapes.each do |name, meta|
|
|
689
|
+
tn = meta[:target_node]
|
|
690
|
+
next if tn.nil?
|
|
691
|
+
if tn.to_s =~ /^#{Regexp.escape(graph_name)}(.+)Shape$/
|
|
692
|
+
parent = $1
|
|
693
|
+
map[name] = parent unless parent == name
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
map
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Names of shapes that inherit (directly or transitively, via sh:node) from
|
|
700
|
+
# base_shape_name — i.e. the concrete subclasses of a polymorphic base.
|
|
701
|
+
def descendant_shape_names(base_shape_name)
|
|
702
|
+
parent_of = polymorphic_parent_map
|
|
703
|
+
parent_of.keys.select do |name|
|
|
704
|
+
ancestor = parent_of[name]
|
|
705
|
+
found = false
|
|
706
|
+
while ancestor
|
|
707
|
+
if ancestor == base_shape_name
|
|
708
|
+
found = true
|
|
709
|
+
break
|
|
710
|
+
end
|
|
711
|
+
ancestor = parent_of[ancestor]
|
|
712
|
+
end
|
|
713
|
+
found
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# For a polymorphic id-only stub declared as a base class, ask the store which
|
|
718
|
+
# concrete subclass URI actually holds this id, and return that concrete model
|
|
719
|
+
# class. Returns nil when the declared class has no subclasses (not polymorphic)
|
|
720
|
+
# or no matching subject exists. Write-path only — issues a SPARQL query.
|
|
721
|
+
def resolve_polymorphic_class(stub, sparql, cache = {})
|
|
722
|
+
return nil unless solis_model?(stub) && shallow_stub?(stub) && stub.id
|
|
723
|
+
|
|
724
|
+
base_name = stub.class.name
|
|
725
|
+
# Key by declared class + id: the same id may be referenced through different
|
|
726
|
+
# declared relation types, so a nil for one base must not shadow another.
|
|
727
|
+
cache_key = "#{base_name}|#{stub.id}"
|
|
728
|
+
return cache[cache_key] if cache.key?(cache_key)
|
|
729
|
+
|
|
730
|
+
subclass_names = descendant_shape_names(base_name)
|
|
731
|
+
return cache[cache_key] = nil if subclass_names.empty?
|
|
732
|
+
|
|
733
|
+
graph_name = stub.class.graph_name
|
|
734
|
+
candidates = ([base_name] + subclass_names).uniq.map { |name| "#{graph_name}#{name.tableize}/#{stub.id}" }
|
|
735
|
+
values = candidates.map { |u| "<#{u}>" }.join(' ')
|
|
736
|
+
result = sparql.query("SELECT ?s WHERE { VALUES ?s { #{values} } . ?s ?p ?o } LIMIT 1")
|
|
737
|
+
uri = result.first && result.first[:s] && result.first[:s].to_s
|
|
738
|
+
|
|
739
|
+
klass = nil
|
|
740
|
+
unless uri.nil?
|
|
741
|
+
concrete_name = uri.sub(graph_name, '').split('/').first.classify
|
|
742
|
+
klass = self.class.graph.shape_as_model(concrete_name) if self.class.graph.shape?(concrete_name)
|
|
743
|
+
end
|
|
744
|
+
cache[cache_key] = klass
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Walk the relation tree and re-type every polymorphic base-class id-only stub
|
|
748
|
+
# to its concrete subclass (resolved from the store), so existence checks and
|
|
749
|
+
# emitted reference URIs use the subclass's storage path. Write-path only.
|
|
750
|
+
def resolve_polymorphic_references!(entity, sparql, cache = {}, visited = Set.new)
|
|
751
|
+
return entity if visited.include?(entity.object_id)
|
|
752
|
+
visited << entity.object_id
|
|
753
|
+
entity.class.metadata[:attributes].each do |attr, meta|
|
|
754
|
+
next if meta[:node_kind].nil?
|
|
755
|
+
val = entity.instance_variable_get("@#{attr}")
|
|
756
|
+
next if val.nil?
|
|
757
|
+
if val.is_a?(Array)
|
|
758
|
+
entity.instance_variable_set("@#{attr}", val.map { |v| retype_polymorphic_stub(v, sparql, cache, visited) })
|
|
759
|
+
else
|
|
760
|
+
entity.instance_variable_set("@#{attr}", retype_polymorphic_stub(val, sparql, cache, visited))
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
entity
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# Resolve a single relation value: re-type a polymorphic base stub to its
|
|
767
|
+
# concrete subclass, then recurse. Non-model values pass through unchanged.
|
|
768
|
+
def retype_polymorphic_stub(v, sparql, cache, visited)
|
|
769
|
+
return v unless solis_model?(v)
|
|
770
|
+
concrete = resolve_polymorphic_class(v, sparql, cache)
|
|
771
|
+
v = concrete.new({ id: v.id }) if concrete && concrete != v.class
|
|
772
|
+
resolve_polymorphic_references!(v, sparql, cache, visited)
|
|
773
|
+
v
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
# Load the full stored entity for this model's class by id. Returns nil when absent.
|
|
777
|
+
def load_original(id)
|
|
778
|
+
self.query.filter({ language: self.class.language, filters: { id: [id] } })
|
|
779
|
+
.find_all.map { |m| m }&.first
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Load full stored originals for the given embedded models, one query per class.
|
|
783
|
+
# Returns { id => full_entity }.
|
|
784
|
+
def batch_load_originals(models)
|
|
785
|
+
originals = {}
|
|
786
|
+
models.select { |m| solis_model?(m) && m.id }.group_by(&:class).each do |_klass, group|
|
|
787
|
+
ids = group.map(&:id).uniq
|
|
788
|
+
group.first.query
|
|
789
|
+
.filter({ language: group.first.class.language, filters: { id: ids } })
|
|
790
|
+
.find_all.each { |entity| originals[entity.id] = entity }
|
|
791
|
+
end
|
|
792
|
+
originals
|
|
793
|
+
end
|
|
794
|
+
|
|
686
795
|
# Helper method to check if an entity is readonly (code table)
|
|
687
796
|
def readonly_entity?(entity, readonly_list = nil)
|
|
688
797
|
readonly_list ||= (Solis::Options.instance.get[:embedded_readonly] || []).map(&:to_s)
|
|
@@ -783,183 +892,150 @@ values ?s {<#{self.graph_id}>}
|
|
|
783
892
|
Set.new(results.map { |r| r[:o].to_s })
|
|
784
893
|
end
|
|
785
894
|
|
|
786
|
-
|
|
895
|
+
# Build an RDF::Graph for `entity`. Pure: reads only the in-memory entity tree,
|
|
896
|
+
# never the store, and performs no validation. Embedded children are emitted as
|
|
897
|
+
# URI references; when `deep` is true, embedded children that carry their own
|
|
898
|
+
# data (i.e. are not id-only references) are also serialized into the graph.
|
|
899
|
+
def as_graph(entity = self, deep: false)
|
|
787
900
|
graph = RDF::Graph.new
|
|
788
901
|
graph.name = RDF::URI(self.class.graph_name)
|
|
789
|
-
|
|
790
|
-
|
|
902
|
+
serialize_entity(graph, entity, deep, Set.new, [])
|
|
791
903
|
graph
|
|
792
904
|
end
|
|
793
905
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
uuid
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
end
|
|
810
|
-
|
|
811
|
-
if original_klass.nil?
|
|
812
|
-
original_klass = klass
|
|
813
|
-
else
|
|
814
|
-
# A store-fetched original carries id-only stub children, so its subgraph is
|
|
815
|
-
# not re-resolved. In the create path (skip_store_fetch) known_entities is
|
|
816
|
-
# pre-populated with full in-memory entities whose children must still be built.
|
|
817
|
-
resolve_all = false unless skip_store_fetch
|
|
818
|
-
klass.instance_variables.map { |m| m.to_s.gsub(/^@/, '') }
|
|
819
|
-
.select { |s| !["model_name", "model_plural_name"].include?(s) }.each do |attribute|
|
|
820
|
-
data = klass.instance_variable_get("@#{attribute}")
|
|
821
|
-
original_data = original_klass.instance_variable_get("@#{attribute}")
|
|
822
|
-
original_klass.instance_variable_set("@#{attribute}", data) unless original_data.eql?(data)
|
|
823
|
-
end
|
|
824
|
-
end
|
|
825
|
-
|
|
826
|
-
# Cache entity for potential reuse in recursive calls
|
|
827
|
-
known_entities[uuid] = original_klass
|
|
828
|
-
|
|
829
|
-
begin
|
|
830
|
-
make_graph(graph, hierarchy, id, original_klass, klass_metadata, resolve_all, known_entities, skip_store_fetch: skip_store_fetch)
|
|
831
|
-
rescue => e
|
|
832
|
-
Solis::LOGGER.error(e.message)
|
|
833
|
-
raise e
|
|
906
|
+
# Emit `entity`'s own triples (rdf:type + attribute statements) into `graph` and
|
|
907
|
+
# return the entity URI. `visited` guards against emitting the same entity twice;
|
|
908
|
+
# `hierarchy` guards against same-class recursion cycles.
|
|
909
|
+
def serialize_entity(graph, entity, deep, visited, hierarchy)
|
|
910
|
+
uuid = entity.id
|
|
911
|
+
id = build_entity_uri(entity)
|
|
912
|
+
return id if uuid && visited.include?(uuid)
|
|
913
|
+
visited << uuid
|
|
914
|
+
|
|
915
|
+
metadata = entity.class.metadata
|
|
916
|
+
hierarchy.push("#{entity.class.name}(#{uuid})")
|
|
917
|
+
graph << [id, RDF::RDFV.type, metadata[:target_class]]
|
|
918
|
+
|
|
919
|
+
metadata[:attributes].each do |attribute, attr_metadata|
|
|
920
|
+
serialize_attribute(graph, id, entity, attribute, attr_metadata, deep, visited, hierarchy)
|
|
834
921
|
end
|
|
835
922
|
|
|
836
923
|
hierarchy.pop
|
|
837
924
|
id
|
|
925
|
+
rescue StandardError => e
|
|
926
|
+
Solis::LOGGER.error(e.message)
|
|
927
|
+
raise e
|
|
838
928
|
end
|
|
839
929
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
end
|
|
853
|
-
unless original_klass.nil?
|
|
854
|
-
klass = original_klass
|
|
855
|
-
data = klass.instance_variable_get("@#{attribute}")
|
|
856
|
-
end
|
|
857
|
-
end
|
|
858
|
-
# if data is still nil
|
|
859
|
-
raise Solis::Error::InvalidAttributeError, "#{hierarchy.join('.')}~#{klass.class.name}.#{attribute} min=#{metadata[:mincount]} and max=#{metadata[:maxcount]}" if data.nil?
|
|
860
|
-
end
|
|
930
|
+
# Emit the statements for a single attribute of `entity` into `graph`.
|
|
931
|
+
# For embedded attributes the child is emitted as a URI reference; when `deep`
|
|
932
|
+
# is true a child carrying its own data is also serialized into `graph`.
|
|
933
|
+
def serialize_attribute(graph, id, entity, attribute, metadata, deep, visited, hierarchy)
|
|
934
|
+
data = entity.instance_variable_get("@#{attribute}")
|
|
935
|
+
|
|
936
|
+
# cardinality (min) check — mandatory attribute must be present
|
|
937
|
+
if data.nil? && metadata.key?(:mincount) && (metadata[:mincount].nil? || metadata[:mincount] > 0) &&
|
|
938
|
+
graph.query(RDF::Query.new({ attribute.to_sym => { RDF.type => metadata[:node] } })).size == 0
|
|
939
|
+
raise Solis::Error::InvalidAttributeError,
|
|
940
|
+
"#{hierarchy.join('.')}~#{entity.class.name}.#{attribute} min=#{metadata[:mincount]} and max=#{metadata[:maxcount]}"
|
|
941
|
+
end
|
|
861
942
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
end
|
|
943
|
+
# skip if nil or an empty container
|
|
944
|
+
return if data.nil? || ([Hash, Array, String].include?(data.class) && data.empty?)
|
|
865
945
|
|
|
866
|
-
|
|
867
|
-
|
|
946
|
+
case metadata[:datatype_rdf]
|
|
947
|
+
when 'http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON'
|
|
948
|
+
data = data.to_json
|
|
949
|
+
end
|
|
868
950
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
data = data
|
|
951
|
+
# coerce embedded hashes to model instances
|
|
952
|
+
unless metadata[:node_kind].nil?
|
|
953
|
+
model = self.class.graph.shape_as_model(metadata[:datatype].to_s)
|
|
954
|
+
if data.is_a?(Hash)
|
|
955
|
+
data = model.new(data)
|
|
956
|
+
elsif data.is_a?(Array)
|
|
957
|
+
data = data.map { |m| m.is_a?(Hash) ? model.new(m) : m }
|
|
874
958
|
end
|
|
959
|
+
end
|
|
875
960
|
|
|
876
|
-
|
|
877
|
-
unless metadata[:node_kind].nil?
|
|
878
|
-
model = self.class.graph.shape_as_model(metadata[:datatype].to_s)
|
|
879
|
-
if data.is_a?(Hash)
|
|
880
|
-
data = model.new(data)
|
|
881
|
-
elsif data.is_a?(Array)
|
|
882
|
-
data = data.map { |m| m.is_a?(Hash) ? model.new(m) : m }
|
|
883
|
-
end
|
|
884
|
-
end
|
|
961
|
+
data = [data] unless data.is_a?(Array)
|
|
885
962
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
internal_resolve = false
|
|
893
|
-
d = build_ttl_objekt(graph, d, hierarchy, internal_resolve, known_entities)
|
|
894
|
-
elsif self.class.graph.shape_as_model(d.class.name) && hierarchy.select { |s| s =~ /^#{d.class.name}/ }.size == 0
|
|
895
|
-
internal_resolve = false
|
|
896
|
-
d = build_ttl_objekt(graph, d, hierarchy, internal_resolve, known_entities)
|
|
897
|
-
else
|
|
898
|
-
d = "#{klass.class.graph_name}#{d.class.name.tableize}/#{d.id}"
|
|
899
|
-
end
|
|
900
|
-
elsif solis_model?(d) && self.class.graph.shape?(d.class.name)
|
|
901
|
-
d = "#{klass.class.graph_name}#{d.class.name.tableize}/#{d.id}"
|
|
963
|
+
data.each do |d|
|
|
964
|
+
if solis_model?(d) && self.class.graph.shape?(d.class.name)
|
|
965
|
+
if deep && !shallow_stub?(d) && hierarchy.none? { |s| s.start_with?("#{d.class.name}(") }
|
|
966
|
+
d = serialize_entity(graph, d, deep, visited, hierarchy)
|
|
967
|
+
else
|
|
968
|
+
d = "#{self.class.graph_name}#{d.class.name.tableize}/#{d.id}"
|
|
902
969
|
end
|
|
970
|
+
end
|
|
903
971
|
|
|
904
|
-
|
|
905
|
-
d = d.first
|
|
906
|
-
end
|
|
972
|
+
d = d.first if d.is_a?(Array) && d.length == 1
|
|
907
973
|
|
|
908
|
-
|
|
909
|
-
if d.is_a?(Hash) && (d.keys - ["@language", "@value"]).size == 0
|
|
910
|
-
if d['@value'].is_a?(Array)
|
|
911
|
-
d_r = []
|
|
912
|
-
d['@value'].each do |v|
|
|
913
|
-
d_r << RDF::Literal.new(v, language: d['@language'])
|
|
914
|
-
end
|
|
915
|
-
d_r
|
|
916
|
-
else
|
|
917
|
-
RDF::Literal.new(d['@value'], language: d['@language'])
|
|
918
|
-
end
|
|
919
|
-
else
|
|
920
|
-
RDF::Literal.new(d, language: @language)
|
|
921
|
-
end
|
|
922
|
-
elsif metadata[:datatype_rdf].eql?('http://www.w3.org/2001/XMLSchema#anyURI')
|
|
923
|
-
RDF::Literal.new(d.to_s, datatype: RDF::XSD.anyURI)
|
|
924
|
-
elsif metadata[:node].is_a?(RDF::URI)
|
|
925
|
-
RDF::URI(d)
|
|
926
|
-
elsif metadata[:datatype_rdf] =~ /datatypes\/edtf/ || metadata[:datatype_rdf] =~ /edtf$/i
|
|
927
|
-
# Handle EDTF dates
|
|
928
|
-
begin
|
|
929
|
-
RDF::Literal::EDTF.new(d)
|
|
930
|
-
rescue StandardError => e
|
|
931
|
-
raise Solis::Error::InvalidDatatypeError, "#{hierarchy.join('.')}.#{attribute}: #{e.message}"
|
|
932
|
-
end
|
|
933
|
-
elsif metadata[:datatype_rdf].eql?('http://www.w3.org/2006/time#DateTimeInterval')
|
|
934
|
-
begin
|
|
935
|
-
datatype = metadata[:datatype_rdf]
|
|
936
|
-
RDF::Literal.new(ISO8601::TimeInterval.parse(d).to_s, datatype: datatype)
|
|
937
|
-
rescue StandardError => e
|
|
938
|
-
raise Solis::Error::InvalidDatatypeError, "#{hierarchy.join('.')}.#{attribute}: #{e.message}"
|
|
939
|
-
end
|
|
940
|
-
else
|
|
941
|
-
datatype = RDF::Vocabulary.find_term(metadata[:datatype_rdf])
|
|
942
|
-
datatype = metadata[:node] if datatype.nil?
|
|
943
|
-
datatype = metadata[:datatype_rdf] if datatype.nil?
|
|
944
|
-
RDF::Literal.new(d, datatype: datatype)
|
|
945
|
-
end
|
|
974
|
+
d = coerce_literal(d, metadata, attribute, hierarchy)
|
|
946
975
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
976
|
+
Array(d).each { |v| graph << [id, RDF::URI("#{metadata[:path]}"), v] }
|
|
977
|
+
end
|
|
978
|
+
end
|
|
950
979
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
980
|
+
# Coerce a serialized value to its RDF term according to the attribute datatype.
|
|
981
|
+
def coerce_literal(d, metadata, attribute, hierarchy)
|
|
982
|
+
if metadata[:datatype_rdf].eql?('http://www.w3.org/1999/02/22-rdf-syntax-ns#langString')
|
|
983
|
+
if d.is_a?(Hash) && (d.keys - ["@language", "@value"]).size == 0
|
|
984
|
+
if d['@value'].is_a?(Array)
|
|
985
|
+
d['@value'].map { |v| RDF::Literal.new(v, language: d['@language']) }
|
|
955
986
|
else
|
|
956
|
-
|
|
987
|
+
RDF::Literal.new(d['@value'], language: d['@language'])
|
|
957
988
|
end
|
|
989
|
+
else
|
|
990
|
+
RDF::Literal.new(d, language: @language)
|
|
991
|
+
end
|
|
992
|
+
elsif metadata[:datatype_rdf].eql?('http://www.w3.org/2001/XMLSchema#anyURI')
|
|
993
|
+
RDF::Literal.new(d.to_s, datatype: RDF::XSD.anyURI)
|
|
994
|
+
elsif metadata[:node].is_a?(RDF::URI)
|
|
995
|
+
RDF::URI(d)
|
|
996
|
+
elsif metadata[:datatype_rdf] =~ /datatypes\/edtf/ || metadata[:datatype_rdf] =~ /edtf$/i
|
|
997
|
+
begin
|
|
998
|
+
RDF::Literal::EDTF.new(d)
|
|
999
|
+
rescue StandardError => e
|
|
1000
|
+
raise Solis::Error::InvalidDatatypeError, "#{hierarchy.join('.')}.#{attribute}: #{e.message}"
|
|
958
1001
|
end
|
|
1002
|
+
elsif metadata[:datatype_rdf].eql?('http://www.w3.org/2006/time#DateTimeInterval')
|
|
1003
|
+
begin
|
|
1004
|
+
RDF::Literal.new(ISO8601::TimeInterval.parse(d).to_s, datatype: metadata[:datatype_rdf])
|
|
1005
|
+
rescue StandardError => e
|
|
1006
|
+
raise Solis::Error::InvalidDatatypeError, "#{hierarchy.join('.')}.#{attribute}: #{e.message}"
|
|
1007
|
+
end
|
|
1008
|
+
else
|
|
1009
|
+
datatype = RDF::Vocabulary.find_term(metadata[:datatype_rdf])
|
|
1010
|
+
datatype = metadata[:node] if datatype.nil?
|
|
1011
|
+
datatype = metadata[:datatype_rdf] if datatype.nil?
|
|
1012
|
+
RDF::Literal.new(d, datatype: datatype)
|
|
1013
|
+
end
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
# Validate a serialized insert graph according to the configured :validation mode
|
|
1017
|
+
# (Solis::Options key :validation):
|
|
1018
|
+
# :cardinality (default) — no extra check; minCount is already enforced inline
|
|
1019
|
+
# during serialization (raises InvalidAttributeError).
|
|
1020
|
+
# :warn — run full SHACL, log every non-conformance as a warning.
|
|
1021
|
+
# :full — run full SHACL, raise InvalidAttributeError on any.
|
|
1022
|
+
def validate_graph(graph)
|
|
1023
|
+
mode = (Solis::Options.instance.get[:validation] || :cardinality).to_sym
|
|
1024
|
+
return if mode == :cardinality
|
|
1025
|
+
|
|
1026
|
+
shapes = SHACL.get_shapes(self.class.graph.instance_variable_get(:@graph))
|
|
1027
|
+
report = shapes.execute(graph)
|
|
1028
|
+
return if report.conform?
|
|
1029
|
+
|
|
1030
|
+
messages = Array(report.results).map do |r|
|
|
1031
|
+
r.respond_to?(:message) ? Array(r.message).join(', ') : r.to_s
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
if mode == :warn
|
|
1035
|
+
messages.each { |m| Solis::LOGGER.warn("SHACL: #{m}") }
|
|
1036
|
+
else
|
|
1037
|
+
raise Solis::Error::InvalidAttributeError, "SHACL validation failed: #{messages.join('; ')}"
|
|
959
1038
|
end
|
|
960
|
-
rescue StandardError => e
|
|
961
|
-
Solis::LOGGER.error(e.message)
|
|
962
|
-
raise e
|
|
963
1039
|
end
|
|
964
1040
|
|
|
965
1041
|
def properties_to_hash(model)
|
data/lib/solis/version.rb
CHANGED