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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/solis/model.rb +311 -235
  3. data/lib/solis/version.rb +1 -1
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02e35b7cfb4fd0efe75a619eb1261dabde1d6ea130ba4508fb4bac17a644207b
4
- data.tar.gz: f04e921636d9aed215883796f83904cc24a618d3c8f7697ef2f691ca3e7768fa
3
+ metadata.gz: 6d5d2032cb4615c4ab9d41f6ff2b89f6000fcc5bfc29ff55972305166b2f7904
4
+ data.tar.gz: 572b1eacf27a9e63579e4403c9f00c003c8dde292b28492960669aa933b8755f
5
5
  SHA512:
6
- metadata.gz: 4e22f052c08a4e7cebcb2c78a114b752d2fe00ee898caf1e23d9331aef19cf55aa4616501bcfe0684c048e8f232e2d7304973368a5b6006321fb6ccd41ab9e55
7
- data.tar.gz: d27220cf49606664cc05aacbad1d8f67a1282c0badb5bae1017535888ebf952ca87da6f2de48309f50bd058ffa97b4bd6342a5369193892fc8c25eab70ca9b0e
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
- if value.key?('id') && value['id'].match?(self.class.graph_name)
30
- inner_class = value['id'].gsub(self.class.graph_name, '').split('/').first.classify.to_s
31
- if inner_model.descendants.map(&:to_s).include?(inner_class)
32
- inner_model = self.class.graph.shape_as_model(inner_class)
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, false)
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
- # Collect all embedded entities first for batched existence check
164
- all_embedded = []
165
- attributes.each_pair do |key, value|
166
- unless self.class.metadata[:attributes][key][:node].nil?
167
- value = [value] unless value.is_a?(Array)
168
- value.each do |sub_value|
169
- embedded = self.class.graph.shape_as_model(self.class.metadata[:attributes][key][:datatype].to_s).new(sub_value)
170
- all_embedded << embedded
171
- end
172
- end
173
- end
174
-
175
- # Batch check existence of all embedded entities in one query
176
- existing_ids = self.class.batch_exists?(sparql, all_embedded)
177
-
178
- # Separate embedded entities into creates and updates
179
- to_create_embedded = []
180
- to_update_embedded = []
181
-
182
- all_embedded.each do |embedded|
183
- entity_exists = existing_ids.include?(embedded.graph_id)
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
- to_update_embedded << embedded
192
+ existing_embedded << entity
191
193
  else
192
- to_create_embedded << embedded
194
+ new_entities << entity
193
195
  end
194
196
  end
195
197
 
196
- # Batch insert all new embedded entities in one SPARQL INSERT
197
- unless to_create_embedded.empty?
198
- embedded_graph = RDF::Graph.new
199
- embedded_graph.name = RDF::URI(self.class.graph_name)
200
- known = {}
201
- to_create_embedded.each { |e| collect_known_entities(e, known) }
202
- to_create_embedded.each { |e| build_ttl_objekt(embedded_graph, e, [], validate_dependencies, known, skip_store_fetch: true) }
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
- # Updates still processed individually (each needs its own DELETE/INSERT)
207
- to_update_embedded.each do |embedded|
208
- embedded_data = properties_to_hash(embedded)
209
- embedded.update(embedded_data, validate_dependencies, false)
210
- end
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 = as_graph(self, validate_dependencies, {}, skip_store_fetch: true)
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
- original_klass = prefetched_original
254
- # A prefetched original may be an id-only stub (embedded relations are loaded that
255
- # way by Query#graph_to_object). It cannot seed updated_klass — omitted mandatory
256
- # attributes would be lost — so re-load the full entity from the store.
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
- # Build lookup of original embedded entities by ID for pre-fetched updates
283
- original_embedded_lookup = {}
284
- embedded_by_key.each_key do |key|
285
- orig = original_klass.instance_variable_get("@#{key}")
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
- # Pass pre-fetched original to avoid N+1 query in embedded update
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
- # Pre-populate known entities separately to avoid cross-contamination
373
- # between delete and insert graphs (same ID, different attribute values)
374
- delete_known = collect_known_entities(original_klass)
375
- delete_graph = as_graph(original_klass, false, delete_known)
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
- insert_known = collect_known_entities(updated_klass)
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(:build_ttl_objekt, combined_graph, entity, [], validate_dependencies, known, skip_store_fetch: true)
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 all in-memory entities by UUID.
658
- # Prevents redundant store fetches during recursive graph building.
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
- def as_graph(klass = self, resolve_all = true, known_entities = {}, skip_store_fetch: false)
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
- id = build_ttl_objekt(graph, klass, [], resolve_all, known_entities, skip_store_fetch: skip_store_fetch)
790
-
902
+ serialize_entity(graph, entity, deep, Set.new, [])
791
903
  graph
792
904
  end
793
905
 
794
- def build_ttl_objekt(graph, klass, hierarchy = [], resolve_all = true, known_entities = {}, skip_store_fetch: false)
795
- hierarchy.push("#{klass.class.name}(#{klass.instance_variables.include?(:@id) ? klass.instance_variable_get("@id") : ''})")
796
-
797
- graph_name = self.class.graph_name
798
- klass_name = klass.class.name
799
- klass_metadata = klass.class.metadata
800
- uuid = klass.instance_variable_get("@id") || SecureRandom.uuid
801
- id = RDF::URI("#{graph_name}#{klass_name.tableize}/#{uuid}")
802
-
803
- graph << [id, RDF::RDFV.type, klass_metadata[:target_class]]
804
-
805
- # Use cached entity if available, otherwise query the store (unless skip_store_fetch)
806
- original_klass = known_entities[uuid]
807
- if original_klass.nil? && !skip_store_fetch
808
- original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
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
- def make_graph(graph, hierarchy, id, klass, klass_metadata, resolve_all, known_entities = {}, skip_store_fetch: false)
841
- klass_metadata[:attributes].each do |attribute, metadata|
842
- data = klass.instance_variable_get("@#{attribute}")
843
-
844
- if data.nil? && metadata.key?(:mincount) && (metadata[:mincount].nil? || metadata[:mincount] > 0) && graph.query(RDF::Query.new({ attribute.to_sym => { RDF.type => metadata[:node] } })).size == 0
845
- if data.nil?
846
- uuid = id.value.split('/').last
847
- # Use cached entity if available (skip store fetch for new entities)
848
- original_klass = known_entities[uuid]
849
- if original_klass.nil? && !skip_store_fetch
850
- original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
851
- known_entities[uuid] = original_klass if original_klass
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
- if data && metadata.key?(:maxcount) && (metadata[:maxcount] && metadata[:maxcount] > 0) && graph.query(SPARQL.parse("select (count(?s) as ?max_subject) where { ?s #{self.class.graph_prefix}:#{attribute} ?p}")).first.max_subject > metadata[:maxcount].to_i
863
- raise Solis::Error::InvalidAttributeError, "#{hierarchy.join('.')}~#{klass.class.name}.#{attribute} min=#{metadata[:mincount]} and max=#{metadata[:maxcount]}" if data.nil?
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
- # skip if nil or an object that is empty
867
- next if data.nil? || ([Hash, Array, String].include?(data.class) && data&.empty?)
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
- case metadata[:datatype_rdf]
870
- when 'http://www.w3.org/2001/XMLSchema#boolean'
871
- data = false if data.nil?
872
- when 'http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON'
873
- data = data.to_json
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
- # make it an object
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
- data = [data] unless data.is_a?(Array)
887
-
888
- data.each do |d|
889
- if solis_model?(d) && self.class.graph.shape?(d.class.name) && resolve_all
890
- if self.class.graph.shape_as_model(d.class.name).metadata[:attributes].select { |_, v| v[:node_kind].is_a?(RDF::URI) }.size > 0 &&
891
- hierarchy.select { |s| s =~ /^#{d.class.name}/ }.size == 0
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
- if d.is_a?(Array) && d.length == 1
905
- d = d.first
906
- end
972
+ d = d.first if d.is_a?(Array) && d.length == 1
907
973
 
908
- d = if metadata[:datatype_rdf].eql?('http://www.w3.org/1999/02/22-rdf-syntax-ns#langString')
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
- unless d.valid?
948
- LOGGER.warn("Invalid datatype for #{hierarchy.join('.')}.#{attribute}")
949
- end
976
+ Array(d).each { |v| graph << [id, RDF::URI("#{metadata[:path]}"), v] }
977
+ end
978
+ end
950
979
 
951
- if d.is_a?(Array)
952
- d.each do |v|
953
- graph << [id, RDF::URI("#{metadata[:path]}"), v]
954
- end
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
- graph << [id, RDF::URI("#{metadata[:path]}"), d]
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
@@ -1,3 +1,3 @@
1
1
  module Solis
2
- VERSION = "0.122.0"
2
+ VERSION = "0.124.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.122.0
4
+ version: 0.124.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehmet Celik