solis 0.122.0 → 0.123.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 +187 -227
  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: 7d9aacb8e97e1c584368f1c792a26ca48b8f885e64faa6c9d39f09f59a2b1260
4
+ data.tar.gz: 1caf4c17d138b900041f758f493a1fccdb302eb27b86651911dd22c529545219
5
5
  SHA512:
6
- metadata.gz: 4e22f052c08a4e7cebcb2c78a114b752d2fe00ee898caf1e23d9331aef19cf55aa4616501bcfe0684c048e8f232e2d7304973368a5b6006321fb6ccd41ab9e55
7
- data.tar.gz: d27220cf49606664cc05aacbad1d8f67a1282c0badb5bae1017535888ebf952ca87da6f2de48309f50bd058ffa97b4bd6342a5369193892fc8c25eab70ca9b0e
6
+ metadata.gz: 49e3387254454c6bbea21ab51f3bbe2440022dd4f23eaffb77351aeb5e6cf967e79d2f9e6e25fc971c3614aa128afe82c6b63c6116e3f195e5bc535657e7dd8a
7
+ data.tar.gz: 9e8478ee6bfe98cf1d3a7dae252383120ff93bb1684a0e8f2bb1aae1296ad6e0472b8b7a34042be1b8b9a90a02d99c8c413c487e1ed298622b22c2c83100ee8e
data/lib/solis/model.rb CHANGED
@@ -87,22 +87,22 @@ module Solis
87
87
  end
88
88
 
89
89
  def to_ttl(resolve_all = true)
90
- graph = as_graph(self, resolve_all)
90
+ graph = as_graph(self, deep: resolve_all)
91
91
  graph.dump(:ttl)
92
92
  end
93
93
 
94
94
  def dump(format = :ttl, resolve_all = true)
95
- graph = as_graph(self, resolve_all)
95
+ graph = as_graph(self, deep: resolve_all)
96
96
  graph.dump(format)
97
97
  end
98
98
 
99
99
  def to_graph(resolve_all = true)
100
- as_graph(self, resolve_all)
100
+ as_graph(self, deep: resolve_all)
101
101
  end
102
102
 
103
103
  def valid?
104
104
  begin
105
- graph = as_graph(self, false)
105
+ graph = as_graph(self)
106
106
  rescue Solis::Error::InvalidAttributeError => e
107
107
  Solis::LOGGER.error(e.message)
108
108
  end
@@ -156,60 +156,44 @@ values ?s {<#{self.graph_id}>}
156
156
  data = properties_to_hash(self)
157
157
  result = update(data, validate_dependencies, top_level, sparql)
158
158
  else
159
- data = properties_to_hash(self)
160
- attributes = data.include?('attributes') ? data['attributes'] : data
161
159
  readonly_list = (Solis::Options.instance.get[:embedded_readonly] || []).map(&:to_s)
162
160
 
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
161
+ # Enumerate the whole in-memory tree: self plus every embedded descendant.
162
+ all_entities = collect_known_entities(self).values
163
+ existing_ids = self.class.batch_exists?(sparql, all_entities)
164
+
165
+ # Classify each entity: new (insert), existing embedded (update), or readonly.
166
+ # readonly only protects EMBEDDED entities; the entity being saved (self) is
167
+ # always created even when its class is a code table.
168
+ new_entities = []
169
+ existing_embedded = []
170
+ all_entities.each do |entity|
171
+ entity_exists = existing_ids.include?(entity.graph_id)
172
+ if !entity.equal?(self) && readonly_entity?(entity, readonly_list)
173
+ Solis::LOGGER.warn("#{entity.class.name} (id: #{entity.id}) is readonly but does not exist in database. Skipping.") unless entity_exists
189
174
  elsif entity_exists
190
- to_update_embedded << embedded
175
+ existing_embedded << entity
191
176
  else
192
- to_create_embedded << embedded
177
+ new_entities << entity
193
178
  end
194
179
  end
195
180
 
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)
181
+ # Existing embedded entities are updated individually (each needs DELETE/INSERT).
182
+ unless existing_embedded.empty?
183
+ embedded_originals = batch_load_originals(existing_embedded)
184
+ existing_embedded.each do |embedded|
185
+ embedded.update(properties_to_hash(embedded), validate_dependencies, false, nil,
186
+ prefetched_original: embedded_originals[embedded.id])
187
+ end
204
188
  end
205
189
 
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
190
+ # Serialize self and every new embedded entity into one INSERT DATA operation.
191
+ graph = RDF::Graph.new
192
+ graph.name = RDF::URI(self.class.graph_name)
193
+ visited = Set.new
194
+ new_entities.each { |entity| serialize_entity(graph, entity, false, visited, []) }
211
195
 
212
- graph = as_graph(self, validate_dependencies, {}, skip_store_fetch: true)
196
+ validate_graph(graph) if validate_dependencies
213
197
 
214
198
  Solis::LOGGER.info SPARQL::Client::Update::InsertData.new(graph, graph: graph.name).to_s if ConfigFile[:debug]
215
199
 
@@ -250,12 +234,10 @@ values ?s {<#{self.graph_id}>}
250
234
  id = attributes.delete('id')
251
235
  sparql = sparql_client || SPARQL::Client.new(self.class.sparql_endpoint)
252
236
 
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
237
+ # prefetched_original is used only when it is a complete entity; an id-only stub
238
+ # cannot seed updated_klass (omitted mandatory attributes would be lost).
239
+ original_klass = prefetched_original unless prefetched_original && shallow_stub?(prefetched_original)
240
+ original_klass ||= load_original(id)
259
241
  raise Solis::Error::NotFoundError if original_klass.nil?
260
242
  updated_klass = original_klass.deep_dup
261
243
 
@@ -279,15 +261,12 @@ values ?s {<#{self.graph_id}>}
279
261
  all_embedded = embedded_by_key.values.flatten
280
262
  existing_ids = self.class.batch_exists?(sparql, all_embedded)
281
263
 
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
264
+ # Batch-load full stored originals for embedded entities that already exist, so
265
+ # each recursive embedded update receives a complete original (one query per class).
266
+ existing_embedded = all_embedded.select do |e|
267
+ existing_ids.include?(e.graph_id) && !readonly_entity?(e, readonly_list)
290
268
  end
269
+ embedded_originals = batch_load_originals(existing_embedded)
291
270
 
292
271
  # Second pass: process embedded entities using batched results
293
272
  embedded_by_key.each do |key, embedded_list|
@@ -319,9 +298,7 @@ values ?s {<#{self.graph_id}>}
319
298
  else
320
299
  if entity_exists
321
300
  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)
301
+ embedded.update(embedded_data, validate_dependencies, false, nil, prefetched_original: embedded_originals[embedded.id])
325
302
  new_embedded_values << embedded
326
303
  else
327
304
  embedded_value = embedded.save(validate_dependencies, false)
@@ -369,10 +346,11 @@ values ?s {<#{self.graph_id}>}
369
346
  Solis::LOGGER.info("#{original_klass.class.name} unchanged, skipping")
370
347
  data = original_klass
371
348
  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)
349
+ # The delete graph carries the stored original's triples; the insert graph the
350
+ # updated entity's. Embedded children are emitted as URI references in both —
351
+ # they are persisted by their own recursive update/save above.
352
+ delete_graph = as_graph(original_klass, deep: false)
353
+ insert_graph = as_graph(updated_klass, deep: false)
376
354
  where_graph = RDF::Graph.new(graph_name: RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), data: RDF::Repository.new)
377
355
 
378
356
  if id.is_a?(Array)
@@ -383,8 +361,7 @@ values ?s {<#{self.graph_id}>}
383
361
  where_graph << [RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), :p, :o]
384
362
  end
385
363
 
386
- insert_known = collect_known_entities(updated_klass)
387
- insert_graph = as_graph(updated_klass, true, insert_known)
364
+ validate_graph(insert_graph) if validate_dependencies
388
365
 
389
366
  delete_insert_query = SPARQL::Client::Update::DeleteInsert.new(delete_graph, insert_graph, where_graph, graph: insert_graph.name).to_s
390
367
  delete_insert_query.gsub!('_:p', '?p')
@@ -412,7 +389,7 @@ values ?s {<#{self.graph_id}>}
412
389
 
413
390
  data
414
391
  rescue StandardError => e
415
- original_graph = as_graph(original_klass, false) if defined?(original_klass) && original_klass
392
+ original_graph = as_graph(original_klass, deep: false) if defined?(original_klass) && original_klass
416
393
  Solis::LOGGER.error(e.message)
417
394
  Solis::LOGGER.error original_graph.dump(:ttl) if defined?(original_graph) && original_graph
418
395
  Solis::LOGGER.error delete_insert_query if defined?(delete_insert_query)
@@ -464,14 +441,11 @@ values ?s {<#{self.graph_id}>}
464
441
  to_create.each_slice(batch_size) do |batch|
465
442
  combined_graph = RDF::Graph.new
466
443
  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) }
444
+ visited = Set.new
471
445
 
472
446
  batch.each do |entity|
473
447
  entity.before_create_proc&.call(entity)
474
- entity.send(:build_ttl_objekt, combined_graph, entity, [], validate_dependencies, known, skip_store_fetch: true)
448
+ entity.send(:serialize_entity, combined_graph, entity, true, visited, [])
475
449
  end
476
450
 
477
451
  sparql.insert_data(combined_graph, graph: combined_graph.name)
@@ -654,8 +628,8 @@ values ?s {<#{self.graph_id}>}
654
628
 
655
629
  private
656
630
 
657
- # Walk the entity tree and collect all in-memory entities by UUID.
658
- # Prevents redundant store fetches during recursive graph building.
631
+ # Walk the in-memory entity tree and collect every entity by UUID
632
+ # ({ uuid => entity }), following embedded (node_kind) attributes.
659
633
  def collect_known_entities(entity, collected = {})
660
634
  uuid = entity.instance_variable_get("@id")
661
635
  return collected if uuid.nil? || collected.key?(uuid)
@@ -683,6 +657,25 @@ values ?s {<#{self.graph_id}>}
683
657
  end
684
658
  end
685
659
 
660
+ # Load the full stored entity for this model's class by id. Returns nil when absent.
661
+ def load_original(id)
662
+ self.query.filter({ language: self.class.language, filters: { id: [id] } })
663
+ .find_all.map { |m| m }&.first
664
+ end
665
+
666
+ # Load full stored originals for the given embedded models, one query per class.
667
+ # Returns { id => full_entity }.
668
+ def batch_load_originals(models)
669
+ originals = {}
670
+ models.select { |m| solis_model?(m) && m.id }.group_by(&:class).each do |_klass, group|
671
+ ids = group.map(&:id).uniq
672
+ group.first.query
673
+ .filter({ language: group.first.class.language, filters: { id: ids } })
674
+ .find_all.each { |entity| originals[entity.id] = entity }
675
+ end
676
+ originals
677
+ end
678
+
686
679
  # Helper method to check if an entity is readonly (code table)
687
680
  def readonly_entity?(entity, readonly_list = nil)
688
681
  readonly_list ||= (Solis::Options.instance.get[:embedded_readonly] || []).map(&:to_s)
@@ -783,183 +776,150 @@ values ?s {<#{self.graph_id}>}
783
776
  Set.new(results.map { |r| r[:o].to_s })
784
777
  end
785
778
 
786
- def as_graph(klass = self, resolve_all = true, known_entities = {}, skip_store_fetch: false)
779
+ # Build an RDF::Graph for `entity`. Pure: reads only the in-memory entity tree,
780
+ # never the store, and performs no validation. Embedded children are emitted as
781
+ # URI references; when `deep` is true, embedded children that carry their own
782
+ # data (i.e. are not id-only references) are also serialized into the graph.
783
+ def as_graph(entity = self, deep: false)
787
784
  graph = RDF::Graph.new
788
785
  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
-
786
+ serialize_entity(graph, entity, deep, Set.new, [])
791
787
  graph
792
788
  end
793
789
 
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]]
790
+ # Emit `entity`'s own triples (rdf:type + attribute statements) into `graph` and
791
+ # return the entity URI. `visited` guards against emitting the same entity twice;
792
+ # `hierarchy` guards against same-class recursion cycles.
793
+ def serialize_entity(graph, entity, deep, visited, hierarchy)
794
+ uuid = entity.id
795
+ id = build_entity_uri(entity)
796
+ return id if uuid && visited.include?(uuid)
797
+ visited << uuid
804
798
 
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
799
+ metadata = entity.class.metadata
800
+ hierarchy.push("#{entity.class.name}(#{uuid})")
801
+ graph << [id, RDF::RDFV.type, metadata[:target_class]]
810
802
 
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
803
+ metadata[:attributes].each do |attribute, attr_metadata|
804
+ serialize_attribute(graph, id, entity, attribute, attr_metadata, deep, visited, hierarchy)
834
805
  end
835
806
 
836
807
  hierarchy.pop
837
808
  id
809
+ rescue StandardError => e
810
+ Solis::LOGGER.error(e.message)
811
+ raise e
838
812
  end
839
813
 
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}")
814
+ # Emit the statements for a single attribute of `entity` into `graph`.
815
+ # For embedded attributes the child is emitted as a URI reference; when `deep`
816
+ # is true a child carrying its own data is also serialized into `graph`.
817
+ def serialize_attribute(graph, id, entity, attribute, metadata, deep, visited, hierarchy)
818
+ data = entity.instance_variable_get("@#{attribute}")
843
819
 
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
820
+ # cardinality (min) check mandatory attribute must be present
821
+ if data.nil? && metadata.key?(:mincount) && (metadata[:mincount].nil? || metadata[:mincount] > 0) &&
822
+ graph.query(RDF::Query.new({ attribute.to_sym => { RDF.type => metadata[:node] } })).size == 0
823
+ raise Solis::Error::InvalidAttributeError,
824
+ "#{hierarchy.join('.')}~#{entity.class.name}.#{attribute} min=#{metadata[:mincount]} and max=#{metadata[:maxcount]}"
825
+ end
861
826
 
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
827
+ # skip if nil or an empty container
828
+ return if data.nil? || ([Hash, Array, String].include?(data.class) && data.empty?)
865
829
 
866
- # skip if nil or an object that is empty
867
- next if data.nil? || ([Hash, Array, String].include?(data.class) && data&.empty?)
830
+ case metadata[:datatype_rdf]
831
+ when 'http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON'
832
+ data = data.to_json
833
+ end
868
834
 
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
835
+ # coerce embedded hashes to model instances
836
+ unless metadata[:node_kind].nil?
837
+ model = self.class.graph.shape_as_model(metadata[:datatype].to_s)
838
+ if data.is_a?(Hash)
839
+ data = model.new(data)
840
+ elsif data.is_a?(Array)
841
+ data = data.map { |m| m.is_a?(Hash) ? model.new(m) : m }
874
842
  end
843
+ end
875
844
 
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
845
+ data = [data] unless data.is_a?(Array)
885
846
 
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}"
847
+ data.each do |d|
848
+ if solis_model?(d) && self.class.graph.shape?(d.class.name)
849
+ if deep && !shallow_stub?(d) && hierarchy.none? { |s| s.start_with?("#{d.class.name}(") }
850
+ d = serialize_entity(graph, d, deep, visited, hierarchy)
851
+ else
852
+ d = "#{self.class.graph_name}#{d.class.name.tableize}/#{d.id}"
902
853
  end
854
+ end
903
855
 
904
- if d.is_a?(Array) && d.length == 1
905
- d = d.first
906
- end
856
+ d = d.first if d.is_a?(Array) && d.length == 1
907
857
 
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
858
+ d = coerce_literal(d, metadata, attribute, hierarchy)
946
859
 
947
- unless d.valid?
948
- LOGGER.warn("Invalid datatype for #{hierarchy.join('.')}.#{attribute}")
949
- end
860
+ Array(d).each { |v| graph << [id, RDF::URI("#{metadata[:path]}"), v] }
861
+ end
862
+ end
950
863
 
951
- if d.is_a?(Array)
952
- d.each do |v|
953
- graph << [id, RDF::URI("#{metadata[:path]}"), v]
954
- end
864
+ # Coerce a serialized value to its RDF term according to the attribute datatype.
865
+ def coerce_literal(d, metadata, attribute, hierarchy)
866
+ if metadata[:datatype_rdf].eql?('http://www.w3.org/1999/02/22-rdf-syntax-ns#langString')
867
+ if d.is_a?(Hash) && (d.keys - ["@language", "@value"]).size == 0
868
+ if d['@value'].is_a?(Array)
869
+ d['@value'].map { |v| RDF::Literal.new(v, language: d['@language']) }
955
870
  else
956
- graph << [id, RDF::URI("#{metadata[:path]}"), d]
871
+ RDF::Literal.new(d['@value'], language: d['@language'])
957
872
  end
873
+ else
874
+ RDF::Literal.new(d, language: @language)
958
875
  end
876
+ elsif metadata[:datatype_rdf].eql?('http://www.w3.org/2001/XMLSchema#anyURI')
877
+ RDF::Literal.new(d.to_s, datatype: RDF::XSD.anyURI)
878
+ elsif metadata[:node].is_a?(RDF::URI)
879
+ RDF::URI(d)
880
+ elsif metadata[:datatype_rdf] =~ /datatypes\/edtf/ || metadata[:datatype_rdf] =~ /edtf$/i
881
+ begin
882
+ RDF::Literal::EDTF.new(d)
883
+ rescue StandardError => e
884
+ raise Solis::Error::InvalidDatatypeError, "#{hierarchy.join('.')}.#{attribute}: #{e.message}"
885
+ end
886
+ elsif metadata[:datatype_rdf].eql?('http://www.w3.org/2006/time#DateTimeInterval')
887
+ begin
888
+ RDF::Literal.new(ISO8601::TimeInterval.parse(d).to_s, datatype: metadata[:datatype_rdf])
889
+ rescue StandardError => e
890
+ raise Solis::Error::InvalidDatatypeError, "#{hierarchy.join('.')}.#{attribute}: #{e.message}"
891
+ end
892
+ else
893
+ datatype = RDF::Vocabulary.find_term(metadata[:datatype_rdf])
894
+ datatype = metadata[:node] if datatype.nil?
895
+ datatype = metadata[:datatype_rdf] if datatype.nil?
896
+ RDF::Literal.new(d, datatype: datatype)
897
+ end
898
+ end
899
+
900
+ # Validate a serialized insert graph according to the configured :validation mode
901
+ # (Solis::Options key :validation):
902
+ # :cardinality (default) — no extra check; minCount is already enforced inline
903
+ # during serialization (raises InvalidAttributeError).
904
+ # :warn — run full SHACL, log every non-conformance as a warning.
905
+ # :full — run full SHACL, raise InvalidAttributeError on any.
906
+ def validate_graph(graph)
907
+ mode = (Solis::Options.instance.get[:validation] || :cardinality).to_sym
908
+ return if mode == :cardinality
909
+
910
+ shapes = SHACL.get_shapes(self.class.graph.instance_variable_get(:@graph))
911
+ report = shapes.execute(graph)
912
+ return if report.conform?
913
+
914
+ messages = Array(report.results).map do |r|
915
+ r.respond_to?(:message) ? Array(r.message).join(', ') : r.to_s
916
+ end
917
+
918
+ if mode == :warn
919
+ messages.each { |m| Solis::LOGGER.warn("SHACL: #{m}") }
920
+ else
921
+ raise Solis::Error::InvalidAttributeError, "SHACL validation failed: #{messages.join('; ')}"
959
922
  end
960
- rescue StandardError => e
961
- Solis::LOGGER.error(e.message)
962
- raise e
963
923
  end
964
924
 
965
925
  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.123.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.123.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehmet Celik