solis 0.121.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 +186 -233
  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: e8125aeb13e491c0d44821cca3c243754167473029057f2603455f7c8794a43b
4
- data.tar.gz: 28f39b6a84d9cdb20a7f2a57729f570f2b0aa56a84d0a7864ad8a15a108e57b7
3
+ metadata.gz: 7d9aacb8e97e1c584368f1c792a26ca48b8f885e64faa6c9d39f09f59a2b1260
4
+ data.tar.gz: 1caf4c17d138b900041f758f493a1fccdb302eb27b86651911dd22c529545219
5
5
  SHA512:
6
- metadata.gz: 66ee13e892ee4bcfd1d2d8c762a6dc9f014a1cee03339e3440bdad93fc8197550ef9c609993326193a611b8ef86d48d1c17c2d90e7d2b5d46bfa396f08b3015e
7
- data.tar.gz: 351b8b1a0cb8939398f77ae05df71188dd844adb84424cb5a1abe453261e30a86c1d90f3293d3b1598d2acbb0f8052197fd0d5c01559fa257ebbf80f5ee4cdb3
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,7 +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 || 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)
254
241
  raise Solis::Error::NotFoundError if original_klass.nil?
255
242
  updated_klass = original_klass.deep_dup
256
243
 
@@ -274,15 +261,12 @@ values ?s {<#{self.graph_id}>}
274
261
  all_embedded = embedded_by_key.values.flatten
275
262
  existing_ids = self.class.batch_exists?(sparql, all_embedded)
276
263
 
277
- # Build lookup of original embedded entities by ID for pre-fetched updates
278
- original_embedded_lookup = {}
279
- embedded_by_key.each_key do |key|
280
- orig = original_klass.instance_variable_get("@#{key}")
281
- next if orig.nil?
282
- Array(orig).each do |e|
283
- original_embedded_lookup[e.id] = e if solis_model?(e) && e.id
284
- 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)
285
268
  end
269
+ embedded_originals = batch_load_originals(existing_embedded)
286
270
 
287
271
  # Second pass: process embedded entities using batched results
288
272
  embedded_by_key.each do |key, embedded_list|
@@ -314,9 +298,7 @@ values ?s {<#{self.graph_id}>}
314
298
  else
315
299
  if entity_exists
316
300
  embedded_data = properties_to_hash(embedded)
317
- # Pass pre-fetched original to avoid N+1 query in embedded update
318
- prefetched = original_embedded_lookup[embedded.id]
319
- 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])
320
302
  new_embedded_values << embedded
321
303
  else
322
304
  embedded_value = embedded.save(validate_dependencies, false)
@@ -364,10 +346,11 @@ values ?s {<#{self.graph_id}>}
364
346
  Solis::LOGGER.info("#{original_klass.class.name} unchanged, skipping")
365
347
  data = original_klass
366
348
  else
367
- # Pre-populate known entities separately to avoid cross-contamination
368
- # between delete and insert graphs (same ID, different attribute values)
369
- delete_known = collect_known_entities(original_klass)
370
- 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)
371
354
  where_graph = RDF::Graph.new(graph_name: RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), data: RDF::Repository.new)
372
355
 
373
356
  if id.is_a?(Array)
@@ -378,8 +361,7 @@ values ?s {<#{self.graph_id}>}
378
361
  where_graph << [RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), :p, :o]
379
362
  end
380
363
 
381
- insert_known = collect_known_entities(updated_klass)
382
- insert_graph = as_graph(updated_klass, true, insert_known)
364
+ validate_graph(insert_graph) if validate_dependencies
383
365
 
384
366
  delete_insert_query = SPARQL::Client::Update::DeleteInsert.new(delete_graph, insert_graph, where_graph, graph: insert_graph.name).to_s
385
367
  delete_insert_query.gsub!('_:p', '?p')
@@ -407,7 +389,7 @@ values ?s {<#{self.graph_id}>}
407
389
 
408
390
  data
409
391
  rescue StandardError => e
410
- 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
411
393
  Solis::LOGGER.error(e.message)
412
394
  Solis::LOGGER.error original_graph.dump(:ttl) if defined?(original_graph) && original_graph
413
395
  Solis::LOGGER.error delete_insert_query if defined?(delete_insert_query)
@@ -459,14 +441,11 @@ values ?s {<#{self.graph_id}>}
459
441
  to_create.each_slice(batch_size) do |batch|
460
442
  combined_graph = RDF::Graph.new
461
443
  combined_graph.name = RDF::URI(graph_name)
462
-
463
- # Pre-collect known entities from all entities being created
464
- known = {}
465
- batch.each { |e| e.send(:collect_known_entities, e, known) }
444
+ visited = Set.new
466
445
 
467
446
  batch.each do |entity|
468
447
  entity.before_create_proc&.call(entity)
469
- 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, [])
470
449
  end
471
450
 
472
451
  sparql.insert_data(combined_graph, graph: combined_graph.name)
@@ -649,8 +628,8 @@ values ?s {<#{self.graph_id}>}
649
628
 
650
629
  private
651
630
 
652
- # Walk the entity tree and collect all in-memory entities by UUID.
653
- # 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.
654
633
  def collect_known_entities(entity, collected = {})
655
634
  uuid = entity.instance_variable_get("@id")
656
635
  return collected if uuid.nil? || collected.key?(uuid)
@@ -678,6 +657,25 @@ values ?s {<#{self.graph_id}>}
678
657
  end
679
658
  end
680
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
+
681
679
  # Helper method to check if an entity is readonly (code table)
682
680
  def readonly_entity?(entity, readonly_list = nil)
683
681
  readonly_list ||= (Solis::Options.instance.get[:embedded_readonly] || []).map(&:to_s)
@@ -778,195 +776,150 @@ values ?s {<#{self.graph_id}>}
778
776
  Set.new(results.map { |r| r[:o].to_s })
779
777
  end
780
778
 
781
- 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)
782
784
  graph = RDF::Graph.new
783
785
  graph.name = RDF::URI(self.class.graph_name)
784
- id = build_ttl_objekt(graph, klass, [], resolve_all, known_entities, skip_store_fetch: skip_store_fetch)
785
-
786
+ serialize_entity(graph, entity, deep, Set.new, [])
786
787
  graph
787
788
  end
788
789
 
789
- def build_ttl_objekt(graph, klass, hierarchy = [], resolve_all = true, known_entities = {}, skip_store_fetch: false)
790
- hierarchy.push("#{klass.class.name}(#{klass.instance_variables.include?(:@id) ? klass.instance_variable_get("@id") : ''})")
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
791
798
 
792
- graph_name = self.class.graph_name
793
- klass_name = klass.class.name
794
- klass_metadata = klass.class.metadata
795
- uuid = klass.instance_variable_get("@id") || SecureRandom.uuid
796
- id = RDF::URI("#{graph_name}#{klass_name.tableize}/#{uuid}")
799
+ metadata = entity.class.metadata
800
+ hierarchy.push("#{entity.class.name}(#{uuid})")
801
+ graph << [id, RDF::RDFV.type, metadata[:target_class]]
797
802
 
798
- graph << [id, RDF::RDFV.type, klass_metadata[:target_class]]
799
-
800
- # Use cached entity if available, otherwise query the store (unless skip_store_fetch)
801
- original_klass = known_entities[uuid]
802
- if original_klass.nil? && !skip_store_fetch
803
- original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
803
+ metadata[:attributes].each do |attribute, attr_metadata|
804
+ serialize_attribute(graph, id, entity, attribute, attr_metadata, deep, visited, hierarchy)
804
805
  end
805
806
 
806
- if original_klass.nil?
807
- original_klass = klass
808
- else
809
- # A store-fetched original carries id-only stub children, so its subgraph is
810
- # not re-resolved. In the create path (skip_store_fetch) known_entities is
811
- # pre-populated with full in-memory entities whose children must still be built.
812
- resolve_all = false unless skip_store_fetch
813
- klass.instance_variables.map { |m| m.to_s.gsub(/^@/, '') }
814
- .select { |s| !["model_name", "model_plural_name"].include?(s) }.each do |attribute|
815
- data = klass.instance_variable_get("@#{attribute}")
816
- original_data = original_klass.instance_variable_get("@#{attribute}")
817
- original_klass.instance_variable_set("@#{attribute}", data) unless original_data.eql?(data)
818
- end
819
- end
807
+ hierarchy.pop
808
+ id
809
+ rescue StandardError => e
810
+ Solis::LOGGER.error(e.message)
811
+ raise e
812
+ end
820
813
 
821
- # Cache entity for potential reuse in recursive calls
822
- known_entities[uuid] = original_klass
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}")
823
819
 
824
- begin
825
- make_graph(graph, hierarchy, id, original_klass, klass_metadata, resolve_all, known_entities, skip_store_fetch: skip_store_fetch)
826
- rescue => e
827
- Solis::LOGGER.error(e.message)
828
- raise e
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]}"
829
825
  end
830
826
 
831
- hierarchy.pop
832
- id
833
- end
827
+ # skip if nil or an empty container
828
+ return if data.nil? || ([Hash, Array, String].include?(data.class) && data.empty?)
834
829
 
835
- def make_graph(graph, hierarchy, id, klass, klass_metadata, resolve_all, known_entities = {}, skip_store_fetch: false)
836
- # klass may arrive as an id-only stub (embedded relation from Query#graph_to_object,
837
- # or passed via prefetched_original). Resolve it to the full stored entity once so
838
- # every attribute is emitted into the graph.
839
- if !skip_store_fetch && shallow_stub?(klass)
840
- uuid = id.value.split('/').last
841
- fetched = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first
842
- unless fetched.nil?
843
- klass = fetched
844
- known_entities[uuid] = fetched
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
834
+
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 }
845
842
  end
846
843
  end
847
844
 
848
- klass_metadata[:attributes].each do |attribute, metadata|
849
- data = klass.instance_variable_get("@#{attribute}")
850
-
851
- 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
852
- if data.nil?
853
- uuid = id.value.split('/').last
854
- # Use cached entity if available (skip store fetch for new entities)
855
- original_klass = known_entities[uuid]
856
- if original_klass.nil? && !skip_store_fetch
857
- original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
858
- known_entities[uuid] = original_klass if original_klass
859
- end
860
- unless original_klass.nil?
861
- klass = original_klass
862
- data = klass.instance_variable_get("@#{attribute}")
863
- end
845
+ data = [data] unless data.is_a?(Array)
846
+
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}"
864
853
  end
865
- # if data is still nil
866
- raise Solis::Error::InvalidAttributeError, "#{hierarchy.join('.')}~#{klass.class.name}.#{attribute} min=#{metadata[:mincount]} and max=#{metadata[:maxcount]}" if data.nil?
867
854
  end
868
855
 
869
- 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
870
- raise Solis::Error::InvalidAttributeError, "#{hierarchy.join('.')}~#{klass.class.name}.#{attribute} min=#{metadata[:mincount]} and max=#{metadata[:maxcount]}" if data.nil?
871
- end
856
+ d = d.first if d.is_a?(Array) && d.length == 1
872
857
 
873
- # skip if nil or an object that is empty
874
- next if data.nil? || ([Hash, Array, String].include?(data.class) && data&.empty?)
858
+ d = coerce_literal(d, metadata, attribute, hierarchy)
875
859
 
876
- case metadata[:datatype_rdf]
877
- when 'http://www.w3.org/2001/XMLSchema#boolean'
878
- data = false if data.nil?
879
- when 'http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON'
880
- data = data.to_json
881
- end
860
+ Array(d).each { |v| graph << [id, RDF::URI("#{metadata[:path]}"), v] }
861
+ end
862
+ end
882
863
 
883
- # make it an object
884
- unless metadata[:node_kind].nil?
885
- model = self.class.graph.shape_as_model(metadata[:datatype].to_s)
886
- if data.is_a?(Hash)
887
- data = model.new(data)
888
- elsif data.is_a?(Array)
889
- data = data.map { |m| m.is_a?(Hash) ? model.new(m) : m }
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']) }
870
+ else
871
+ RDF::Literal.new(d['@value'], language: d['@language'])
890
872
  end
873
+ else
874
+ RDF::Literal.new(d, language: @language)
891
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
892
899
 
893
- data = [data] unless data.is_a?(Array)
894
-
895
- data.each do |d|
896
- if solis_model?(d) && self.class.graph.shape?(d.class.name) && resolve_all
897
- if self.class.graph.shape_as_model(d.class.name).metadata[:attributes].select { |_, v| v[:node_kind].is_a?(RDF::URI) }.size > 0 &&
898
- hierarchy.select { |s| s =~ /^#{d.class.name}/ }.size == 0
899
- internal_resolve = false
900
- d = build_ttl_objekt(graph, d, hierarchy, internal_resolve, known_entities)
901
- elsif self.class.graph.shape_as_model(d.class.name) && hierarchy.select { |s| s =~ /^#{d.class.name}/ }.size == 0
902
- internal_resolve = false
903
- d = build_ttl_objekt(graph, d, hierarchy, internal_resolve, known_entities)
904
- else
905
- d = "#{klass.class.graph_name}#{d.class.name.tableize}/#{d.id}"
906
- end
907
- elsif solis_model?(d) && self.class.graph.shape?(d.class.name)
908
- d = "#{klass.class.graph_name}#{d.class.name.tableize}/#{d.id}"
909
- end
910
-
911
- if d.is_a?(Array) && d.length == 1
912
- d = d.first
913
- end
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
914
909
 
915
- d = if metadata[:datatype_rdf].eql?('http://www.w3.org/1999/02/22-rdf-syntax-ns#langString')
916
- if d.is_a?(Hash) && (d.keys - ["@language", "@value"]).size == 0
917
- if d['@value'].is_a?(Array)
918
- d_r = []
919
- d['@value'].each do |v|
920
- d_r << RDF::Literal.new(v, language: d['@language'])
921
- end
922
- d_r
923
- else
924
- RDF::Literal.new(d['@value'], language: d['@language'])
925
- end
926
- else
927
- RDF::Literal.new(d, language: @language)
928
- end
929
- elsif metadata[:datatype_rdf].eql?('http://www.w3.org/2001/XMLSchema#anyURI')
930
- RDF::Literal.new(d.to_s, datatype: RDF::XSD.anyURI)
931
- elsif metadata[:node].is_a?(RDF::URI)
932
- RDF::URI(d)
933
- elsif metadata[:datatype_rdf] =~ /datatypes\/edtf/ || metadata[:datatype_rdf] =~ /edtf$/i
934
- # Handle EDTF dates
935
- begin
936
- RDF::Literal::EDTF.new(d)
937
- rescue StandardError => e
938
- raise Solis::Error::InvalidDatatypeError, "#{hierarchy.join('.')}.#{attribute}: #{e.message}"
939
- end
940
- elsif metadata[:datatype_rdf].eql?('http://www.w3.org/2006/time#DateTimeInterval')
941
- begin
942
- datatype = metadata[:datatype_rdf]
943
- RDF::Literal.new(ISO8601::TimeInterval.parse(d).to_s, datatype: datatype)
944
- rescue StandardError => e
945
- raise Solis::Error::InvalidDatatypeError, "#{hierarchy.join('.')}.#{attribute}: #{e.message}"
946
- end
947
- else
948
- datatype = RDF::Vocabulary.find_term(metadata[:datatype_rdf])
949
- datatype = metadata[:node] if datatype.nil?
950
- datatype = metadata[:datatype_rdf] if datatype.nil?
951
- RDF::Literal.new(d, datatype: datatype)
952
- end
910
+ shapes = SHACL.get_shapes(self.class.graph.instance_variable_get(:@graph))
911
+ report = shapes.execute(graph)
912
+ return if report.conform?
953
913
 
954
- unless d.valid?
955
- LOGGER.warn("Invalid datatype for #{hierarchy.join('.')}.#{attribute}")
956
- end
914
+ messages = Array(report.results).map do |r|
915
+ r.respond_to?(:message) ? Array(r.message).join(', ') : r.to_s
916
+ end
957
917
 
958
- if d.is_a?(Array)
959
- d.each do |v|
960
- graph << [id, RDF::URI("#{metadata[:path]}"), v]
961
- end
962
- else
963
- graph << [id, RDF::URI("#{metadata[:path]}"), d]
964
- end
965
- end
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('; ')}"
966
922
  end
967
- rescue StandardError => e
968
- Solis::LOGGER.error(e.message)
969
- raise e
970
923
  end
971
924
 
972
925
  def properties_to_hash(model)
data/lib/solis/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Solis
2
- VERSION = "0.121.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.121.0
4
+ version: 0.123.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehmet Celik