solis 0.115.0 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c9039f8bba386333b11237b9914c7fdeee57ad8094ea57058815f2fc008d4a7
4
- data.tar.gz: ea7269d0380b39c1a88a7d064c503c972194a548441c6c32459ab4ca7187cab8
3
+ metadata.gz: fa9ba6d86b36ebd75e9e0280928ce458f1ae70e4c64f44832c0963870f319b2f
4
+ data.tar.gz: 23c8bd2bcbbb4fe97dc133b3310d1527bab161dbce43e2939b45a8fa1a9b7712
5
5
  SHA512:
6
- metadata.gz: 17d2aa3f39b2bee0cb267996467ba8a0fe08d433e3e63c9dbab3df8624cac6ebf45703bc90c606f3c7d90d4c0ab12b72c3794056c11481f008141c456fe27675
7
- data.tar.gz: 0e9faa327c21a51aa3413ed0d90c20f781186c360ae4e33c3d2b6432c1e9779e19c100008197b4fbed8969eefecdf2d9d5776cf18ab72872f0dff95ac334160b
6
+ metadata.gz: f15183cf04d168b3d06425bfe987d4cc446d0fad1e1b1f94532b8dfedf39b3401d39641226d91a63400f4a4e1c7096a0c995b64e2629d6d07424bae7e4c14375
7
+ data.tar.gz: d7219d14c2290aa045ca80a32a97df1f12fcc4aa0df31fc3628fe91dbd5e2ee7e6239474aef27f4f43caf45fb9347e8cd059a0deabff12a3b2bd754ad1e98342
data/lib/solis/graph.rb CHANGED
@@ -369,6 +369,10 @@ module Solis
369
369
 
370
370
  @sparql_client = Solis::Store::Sparql::Client.new(@sparql_endpoint)
371
371
  result = @sparql_client.query("with <#{graph_name}> delete {?s ?p ?o} where{?s ?p ?o}")
372
+
373
+ # Clear the query cache since all data has been flushed
374
+ Solis::Query.shared_query_cache.clear if defined?(Solis::Query)
375
+
372
376
  LOGGER.info(result)
373
377
  true
374
378
  end
data/lib/solis/model.rb CHANGED
@@ -140,6 +140,9 @@ values ?s {<#{self.graph_id}>}
140
140
  end
141
141
  end
142
142
 
143
+ # Invalidate cached queries for this entity type
144
+ Solis::Query.invalidate_cache_for(self.class.name)
145
+
143
146
  result
144
147
  end
145
148
 
@@ -172,6 +175,10 @@ values ?s {<#{self.graph_id}>}
172
175
  # Batch check existence of all embedded entities in one query
173
176
  existing_ids = self.class.batch_exists?(sparql, all_embedded)
174
177
 
178
+ # Separate embedded entities into creates and updates
179
+ to_create_embedded = []
180
+ to_update_embedded = []
181
+
175
182
  all_embedded.each do |embedded|
176
183
  entity_exists = existing_ids.include?(embedded.graph_id)
177
184
 
@@ -179,23 +186,39 @@ values ?s {<#{self.graph_id}>}
179
186
  unless entity_exists
180
187
  Solis::LOGGER.warn("#{embedded.class.name} (id: #{embedded.id}) is readonly but does not exist in database. Skipping.")
181
188
  end
189
+ elsif entity_exists
190
+ to_update_embedded << embedded
182
191
  else
183
- if entity_exists
184
- embedded_data = properties_to_hash(embedded)
185
- embedded.update(embedded_data, validate_dependencies, false)
186
- else
187
- embedded.save(validate_dependencies, false)
188
- end
192
+ to_create_embedded << embedded
189
193
  end
190
194
  end
191
195
 
192
- graph = as_graph(self, validate_dependencies)
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)
204
+ end
205
+
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
211
+
212
+ graph = as_graph(self, validate_dependencies, {}, skip_store_fetch: true)
193
213
 
194
214
  Solis::LOGGER.info SPARQL::Client::Update::InsertData.new(graph, graph: graph.name).to_s if ConfigFile[:debug]
195
215
 
196
216
  result = sparql.insert_data(graph, graph: graph.name)
197
217
  end
198
218
 
219
+ # Invalidate cached queries for this entity type
220
+ Solis::Query.invalidate_cache_for(self.class.name)
221
+
199
222
  after_create_proc&.call(self)
200
223
  self
201
224
  rescue StandardError => e
@@ -217,7 +240,8 @@ values ?s {<#{self.graph_id}>}
217
240
  # When false (default), uses PUT semantics:
218
241
  # - Embedded entity arrays are fully replaced
219
242
  # - Entities removed from the array are orphaned and deleted if unreferenced
220
- def update(data, validate_dependencies = true, top_level = true, sparql_client = nil, patch: false)
243
+ # @param prefetched_original [Solis::Model, nil] optional pre-fetched original entity to avoid re-querying
244
+ def update(data, validate_dependencies = true, top_level = true, sparql_client = nil, patch: false, prefetched_original: nil)
221
245
  raise Solis::Error::GeneralError, "I need a SPARQL endpoint" if self.class.sparql_endpoint.nil?
222
246
 
223
247
  attributes = data.include?('attributes') ? data['attributes'] : data
@@ -226,7 +250,7 @@ values ?s {<#{self.graph_id}>}
226
250
  id = attributes.delete('id')
227
251
  sparql = sparql_client || SPARQL::Client.new(self.class.sparql_endpoint)
228
252
 
229
- original_klass = self.query.filter({ language: self.class.language, filters: { id: [id] } }).find_all.map { |m| m }&.first
253
+ original_klass = prefetched_original || self.query.filter({ language: self.class.language, filters: { id: [id] } }).find_all.map { |m| m }&.first
230
254
  raise Solis::Error::NotFoundError if original_klass.nil?
231
255
  updated_klass = original_klass.deep_dup
232
256
 
@@ -250,6 +274,16 @@ values ?s {<#{self.graph_id}>}
250
274
  all_embedded = embedded_by_key.values.flatten
251
275
  existing_ids = self.class.batch_exists?(sparql, all_embedded)
252
276
 
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
285
+ end
286
+
253
287
  # Second pass: process embedded entities using batched results
254
288
  embedded_by_key.each do |key, embedded_list|
255
289
  value = attributes[key]
@@ -280,7 +314,9 @@ values ?s {<#{self.graph_id}>}
280
314
  else
281
315
  if entity_exists
282
316
  embedded_data = properties_to_hash(embedded)
283
- embedded.update(embedded_data, validate_dependencies, false)
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)
284
320
  new_embedded_values << embedded
285
321
  else
286
322
  embedded_value = embedded.save(validate_dependencies, false)
@@ -328,9 +364,10 @@ values ?s {<#{self.graph_id}>}
328
364
  Solis::LOGGER.info("#{original_klass.class.name} unchanged, skipping")
329
365
  data = original_klass
330
366
  else
331
- # Pre-populate known entities to avoid re-fetching during graph building
332
- known_entities = { id => original_klass }
333
- delete_graph = as_graph(original_klass, false, known_entities)
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)
334
371
  where_graph = RDF::Graph.new(graph_name: RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), data: RDF::Repository.new)
335
372
 
336
373
  if id.is_a?(Array)
@@ -341,13 +378,17 @@ values ?s {<#{self.graph_id}>}
341
378
  where_graph << [RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), :p, :o]
342
379
  end
343
380
 
344
- insert_graph = as_graph(updated_klass, true, known_entities)
381
+ insert_known = collect_known_entities(updated_klass)
382
+ insert_graph = as_graph(updated_klass, true, insert_known)
345
383
 
346
384
  delete_insert_query = SPARQL::Client::Update::DeleteInsert.new(delete_graph, insert_graph, where_graph, graph: insert_graph.name).to_s
347
385
  delete_insert_query.gsub!('_:p', '?p')
348
386
 
349
387
  sparql.query(delete_insert_query)
350
388
 
389
+ # Invalidate cache before verification to avoid stale reads
390
+ Solis::Query.invalidate_cache_for(self.class.name)
391
+
351
392
  # Verify the update succeeded by re-fetching; fallback to insert if needed
352
393
  data = self.query.filter({ filters: { id: [id] } }).find_all.map { |m| m }&.first
353
394
  if data.nil?
@@ -359,6 +400,9 @@ values ?s {<#{self.graph_id}>}
359
400
  delete_orphaned_entities(entities_to_check_for_deletion, sparql) unless patch
360
401
  end
361
402
 
403
+ # Invalidate cached queries for this entity type
404
+ Solis::Query.invalidate_cache_for(self.class.name)
405
+
362
406
  after_update_proc&.call(updated_klass, data)
363
407
 
364
408
  data
@@ -384,6 +428,65 @@ values ?s {<#{self.graph_id}>}
384
428
  sparql.query("ASK WHERE { <#{self.graph_id}> ?p ?o }")
385
429
  end
386
430
 
431
+ # Save multiple entities in a single SPARQL INSERT operation.
432
+ # Entities that already exist are updated individually.
433
+ # @param entities [Array<Solis::Model>] entities to save
434
+ # @param validate_dependencies [Boolean] whether to validate dependencies
435
+ # @param batch_size [Integer] max entities per INSERT (default 100)
436
+ # @return [Array<Solis::Model>] the saved entities
437
+ def self.batch_save(entities, validate_dependencies: true, batch_size: 100)
438
+ raise "I need a SPARQL endpoint" if sparql_endpoint.nil?
439
+ return [] if entities.empty?
440
+
441
+ sparql = SPARQL::Client.new(sparql_endpoint)
442
+
443
+ # Batch check existence of all entities
444
+ existing_ids = batch_exists?(sparql, entities)
445
+
446
+ to_create = []
447
+ to_update = []
448
+
449
+ entities.each do |entity|
450
+ if existing_ids.include?(entity.graph_id)
451
+ to_update << entity
452
+ else
453
+ to_create << entity
454
+ end
455
+ end
456
+
457
+ # Batch insert: combine new entity graphs into single INSERT DATA operations
458
+ unless to_create.empty?
459
+ to_create.each_slice(batch_size) do |batch|
460
+ combined_graph = RDF::Graph.new
461
+ 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) }
466
+
467
+ batch.each do |entity|
468
+ entity.before_create_proc&.call(entity)
469
+ entity.send(:build_ttl_objekt, combined_graph, entity, [], validate_dependencies, known, skip_store_fetch: true)
470
+ end
471
+
472
+ sparql.insert_data(combined_graph, graph: combined_graph.name)
473
+
474
+ batch.each { |entity| entity.after_create_proc&.call(entity) }
475
+ end
476
+
477
+ # Invalidate cache once for the entity type
478
+ Solis::Query.invalidate_cache_for(name)
479
+ end
480
+
481
+ # Updates still processed individually (DELETE/INSERT requires per-entity WHERE)
482
+ to_update.each do |entity|
483
+ data = entity.send(:properties_to_hash, entity)
484
+ entity.update(data, validate_dependencies, true, sparql)
485
+ end
486
+
487
+ entities
488
+ end
489
+
387
490
  # Check existence of multiple entities in a single SPARQL query
388
491
  # Returns a Set of graph_ids that exist
389
492
  def self.batch_exists?(sparql, entities)
@@ -471,20 +574,23 @@ values ?s {<#{self.graph_id}>}
471
574
  end
472
575
 
473
576
  def self.model(level = 0)
474
- m = { type: self.name.tableize, attributes: {} }
577
+ m = { type: self.name.tableize, attributes: [] }
475
578
  self.metadata[:attributes].each do |attribute, attribute_metadata|
476
- is_array = ((attribute_metadata[:maxcount].nil? || (attribute_metadata[:maxcount].to_i > 1)) && !attribute_metadata[:datatype].eql?(:lang_string))
477
- attribute_name = is_array ? "#{attribute}[]" : attribute
478
- attribute_name = attribute_metadata[:mincount].to_i > 0 ? "#{attribute_name}*" : attribute_name
479
579
  if attribute_metadata.key?(:class) && !attribute_metadata[:class].nil? && attribute_metadata[:class].value =~ /#{self.graph_name}/ && level == 0
480
580
  cm = self.graph.shape_as_model(self.metadata[:attributes][attribute][:datatype].to_s).model(level + 1)
481
- m[:attributes][attribute_name.to_sym] = cm[:attributes]
482
- else
483
- m[:attributes][attribute_name.to_sym] = { description: attribute_metadata[:comment]&.value,
484
- mandatory: (attribute_metadata[:mincount].to_i > 0),
485
- data_type: attribute_metadata[:datatype] }
486
- m[:attributes][attribute_name.to_sym][:order] = attribute_metadata[:order]&.value.to_i if attribute_metadata.key?(:order) && !attribute_metadata[:order].nil?
487
581
  end
582
+
583
+ attribute_data = { name: attribute,
584
+ data_type: attribute_metadata[:datatype],
585
+ mandatory: (attribute_metadata[:mincount].to_i > 0),
586
+ repeatable: (attribute_metadata[:maxcount].to_i > 1 || attribute_metadata[:maxcount].nil?),
587
+ description: attribute_metadata[:comment]&.value
588
+ }
589
+ attribute_data[:order] = attribute_metadata[:order]&.value.to_i if attribute_metadata.key?(:order) && !attribute_metadata[:order].nil?
590
+ attribute_data[:group] = attribute_metadata[:group]&.value.gsub(graph_name,'').gsub(/Group$/,'') if attribute_metadata.key?(:group) && !attribute_metadata[:group].nil?
591
+ attribute_data[:attributes] = cm[:attributes] if cm && cm[:attributes]
592
+
593
+ m[:attributes] << attribute_data
488
594
  end
489
595
 
490
596
  m
@@ -543,6 +649,21 @@ values ?s {<#{self.graph_id}>}
543
649
 
544
650
  private
545
651
 
652
+ # Walk the entity tree and collect all in-memory entities by UUID.
653
+ # Prevents redundant store fetches during recursive graph building.
654
+ def collect_known_entities(entity, collected = {})
655
+ uuid = entity.instance_variable_get("@id")
656
+ return collected if uuid.nil? || collected.key?(uuid)
657
+ collected[uuid] = entity
658
+ entity.class.metadata[:attributes].each do |attr, meta|
659
+ next if meta[:node_kind].nil?
660
+ val = entity.instance_variable_get("@#{attr}")
661
+ next if val.nil?
662
+ Array(val).each { |v| collect_known_entities(v, collected) if solis_model?(v) }
663
+ end
664
+ collected
665
+ end
666
+
546
667
  # Helper method to check if an object is a Solis model
547
668
  def solis_model?(obj)
548
669
  obj.class.ancestors.include?(Solis::Model)
@@ -648,15 +769,15 @@ values ?s {<#{self.graph_id}>}
648
769
  Set.new(results.map { |r| r[:o].to_s })
649
770
  end
650
771
 
651
- def as_graph(klass = self, resolve_all = true, known_entities = {})
772
+ def as_graph(klass = self, resolve_all = true, known_entities = {}, skip_store_fetch: false)
652
773
  graph = RDF::Graph.new
653
774
  graph.name = RDF::URI(self.class.graph_name)
654
- id = build_ttl_objekt(graph, klass, [], resolve_all, known_entities)
775
+ id = build_ttl_objekt(graph, klass, [], resolve_all, known_entities, skip_store_fetch: skip_store_fetch)
655
776
 
656
777
  graph
657
778
  end
658
779
 
659
- def build_ttl_objekt(graph, klass, hierarchy = [], resolve_all = true, known_entities = {})
780
+ def build_ttl_objekt(graph, klass, hierarchy = [], resolve_all = true, known_entities = {}, skip_store_fetch: false)
660
781
  hierarchy.push("#{klass.class.name}(#{klass.instance_variables.include?(:@id) ? klass.instance_variable_get("@id") : ''})")
661
782
 
662
783
  graph_name = self.class.graph_name
@@ -667,9 +788,9 @@ values ?s {<#{self.graph_id}>}
667
788
 
668
789
  graph << [id, RDF::RDFV.type, klass_metadata[:target_class]]
669
790
 
670
- # Use cached entity if available, otherwise query the store
791
+ # Use cached entity if available, otherwise query the store (unless skip_store_fetch)
671
792
  original_klass = known_entities[uuid]
672
- if original_klass.nil?
793
+ if original_klass.nil? && !skip_store_fetch
673
794
  original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
674
795
  end
675
796
 
@@ -689,7 +810,7 @@ values ?s {<#{self.graph_id}>}
689
810
  known_entities[uuid] = original_klass
690
811
 
691
812
  begin
692
- make_graph(graph, hierarchy, id, original_klass, klass_metadata, resolve_all, known_entities)
813
+ make_graph(graph, hierarchy, id, original_klass, klass_metadata, resolve_all, known_entities, skip_store_fetch: skip_store_fetch)
693
814
  rescue => e
694
815
  Solis::LOGGER.error(e.message)
695
816
  raise e
@@ -699,16 +820,16 @@ values ?s {<#{self.graph_id}>}
699
820
  id
700
821
  end
701
822
 
702
- def make_graph(graph, hierarchy, id, klass, klass_metadata, resolve_all, known_entities = {})
823
+ def make_graph(graph, hierarchy, id, klass, klass_metadata, resolve_all, known_entities = {}, skip_store_fetch: false)
703
824
  klass_metadata[:attributes].each do |attribute, metadata|
704
825
  data = klass.instance_variable_get("@#{attribute}")
705
826
 
706
827
  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
707
828
  if data.nil?
708
829
  uuid = id.value.split('/').last
709
- # Use cached entity if available
830
+ # Use cached entity if available (skip store fetch for new entities)
710
831
  original_klass = known_entities[uuid]
711
- if original_klass.nil?
832
+ if original_klass.nil? && !skip_store_fetch
712
833
  original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
713
834
  known_entities[uuid] = original_klass if original_klass
714
835
  end
@@ -824,96 +945,6 @@ values ?s {<#{self.graph_id}>}
824
945
  raise e
825
946
  end
826
947
 
827
- def build_ttl_objekt_old(graph, klass, hierarchy = [], resolve_all = true)
828
- hierarchy.push("#{klass.class.name}(#{klass.instance_variables.include?(:@id) ? klass.instance_variable_get("@id") : ''})")
829
- sparql_endpoint = self.class.sparql_endpoint
830
- if klass.instance_variables.include?(:@id) && hierarchy.length > 1
831
- unless sparql_endpoint.nil?
832
- existing_klass = klass.query.filter({ filters: { id: [klass.instance_variable_get("@id")] } }).find_all { |f| f.id == klass.instance_variable_get("@id") }
833
- if !existing_klass.nil? && !existing_klass.empty? && existing_klass.first.is_a?(klass.class)
834
- klass = existing_klass.first
835
- end
836
- end
837
- end
838
-
839
- uuid = klass.instance_variable_get("@id") || SecureRandom.uuid
840
- id = RDF::URI("#{self.class.graph_name}#{klass.class.name.tableize}/#{uuid}")
841
- graph << [id, RDF::RDFV.type, klass.class.metadata[:target_class]]
842
-
843
- klass.class.metadata[:attributes].each do |attribute, metadata|
844
- data = klass.instance_variable_get("@#{attribute}")
845
- if data.nil? && metadata[:datatype_rdf].eql?('http://www.w3.org/2001/XMLSchema#boolean')
846
- data = false
847
- end
848
-
849
- if metadata[:datatype_rdf].eql?("http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON")
850
- data = data.to_json
851
- end
852
-
853
- if data.nil? && metadata[:mincount] > 0
854
- raise Solis::Error::InvalidAttributeError, "#{hierarchy.join('.')}.#{attribute} min=#{metadata[:mincount]} and max=#{metadata[:maxcount]}"
855
- end
856
-
857
- next if data.nil? || ([Hash, Array, String].include?(data.class) && data&.empty?)
858
-
859
- data = [data] unless data.is_a?(Array)
860
- model = nil
861
- model = klass.class.graph.shape_as_model(klass.class.metadata[:attributes][attribute][:datatype].to_s) unless klass.class.metadata[:attributes][attribute][:node_kind].nil?
862
-
863
- data.each do |d|
864
- original_d = d
865
- if model
866
- target_node = model.metadata[:target_node].value.split('/').last.gsub(/Shape$/, '')
867
- if model.ancestors[0..model.ancestors.find_index(Solis::Model) - 1].map { |m| m.name }.include?(target_node)
868
- parent_model = model.graph.shape_as_model(target_node)
869
- end
870
- end
871
-
872
- if model && d.is_a?(Hash)
873
- model_instance = model.descendants.map { |m| m&.new(d) rescue nil }.compact.first || nil
874
- model_instance = model.new(d) if model_instance.nil?
875
-
876
- if resolve_all
877
- d = build_ttl_objekt(graph, model_instance, hierarchy, false, known_entities)
878
- else
879
- real_model = known_entities[model_instance.id] || model_instance.query.filter({ filters: { id: model_instance.id } }).find_all { |f| f.id == model_instance.id }&.first
880
- d = RDF::URI("#{self.class.graph_name}#{real_model ? real_model.class.name.tableize : model_instance.class.name.tableize}/#{model_instance.id}")
881
- end
882
- elsif model && d.is_a?(model)
883
- if resolve_all
884
- if parent_model
885
- model_instance = parent_model.new({ id: d.id })
886
- d = build_ttl_objekt(graph, model_instance, hierarchy, false, known_entities)
887
- else
888
- d = build_ttl_objekt(graph, d, hierarchy, false, known_entities)
889
- end
890
- else
891
- real_model = known_entities[d.id] || model.new.query.filter({ filters: { id: d.id } }).find_all { |f| f.id == d.id }&.first
892
- d = RDF::URI("#{self.class.graph_name}#{real_model ? real_model.class.name.tableize : model.name.tableize}/#{d.id}")
893
- end
894
- else
895
- datatype = RDF::Vocabulary.find_term(metadata[:datatype_rdf] || metadata[:node])
896
- if datatype && datatype.datatype?
897
- d = if metadata[:datatype_rdf].eql?('http://www.w3.org/1999/02/22-rdf-syntax-ns#langString')
898
- RDF::Literal.new(d, language: self.class.language)
899
- else
900
- if metadata[:datatype_rdf].eql?('http://www.w3.org/2001/XMLSchema#anyURI')
901
- RDF::Literal.new(d.to_s, datatype: RDF::XSD.anyURI)
902
- else
903
- RDF::Literal.new(d, datatype: datatype)
904
- end
905
- end
906
- d = (d.object.value rescue d.object) unless d.valid?
907
- end
908
- end
909
-
910
- graph << [id, RDF::URI("#{self.class.graph_name}#{attribute}"), d]
911
- end
912
- end
913
- hierarchy.pop
914
- id
915
- end
916
-
917
948
  def properties_to_hash(model)
918
949
  n = {}
919
950
  model.class.metadata[:attributes].each_key do |m|
data/lib/solis/query.rb CHANGED
@@ -58,6 +58,30 @@ module Solis
58
58
  Solis::Options.instance.get.key?(:graphs) ? Solis::Options.instance.get[:graphs].select{|s| s['type'].eql?(:main)}&.first['name'] : ''
59
59
  end
60
60
 
61
+ # Shared class-level query cache to ensure consistent reads/writes/invalidations
62
+ def self.shared_query_cache
63
+ cache_dir = File.absolute_path(Solis::Options.instance.get[:cache])
64
+ @shared_query_cache ||= Moneta.new(:HashFile, dir: cache_dir)
65
+ end
66
+
67
+ # Reset the shared cache (useful when config changes, e.g., in tests)
68
+ def self.reset_shared_query_cache!
69
+ @shared_query_cache = nil
70
+ end
71
+
72
+ # Invalidate all cached query results for a given model type.
73
+ def self.invalidate_cache_for(model_class_name, cache_dir = nil)
74
+ cache = shared_query_cache
75
+ tag_key = "TAG:#{model_class_name}"
76
+ if cache.key?(tag_key)
77
+ cache[tag_key].each { |key| cache.delete(key) }
78
+ cache.delete(tag_key)
79
+ Solis::LOGGER.info("CACHE: invalidated entries for #{model_class_name}") if ConfigFile[:debug]
80
+ end
81
+ rescue StandardError => e
82
+ Solis::LOGGER.warn("CACHE: invalidation failed for #{model_class_name}: #{e.message}")
83
+ end
84
+
61
85
  def initialize(model)
62
86
  @construct_cache = File.absolute_path(Solis::Options.instance.get[:cache])
63
87
  @model = model
@@ -73,7 +97,7 @@ module Solis
73
97
  @sort = 'ORDER BY ?s'
74
98
  @sort_select = ''
75
99
  @language = Graphiti.context[:object]&.language || Solis::Options.instance.get[:language] || 'en'
76
- @query_cache = Moneta.new(:HashFile, dir: @construct_cache)
100
+ @query_cache = self.class.shared_query_cache
77
101
  end
78
102
 
79
103
  def each(&block)
@@ -135,6 +159,16 @@ module Solis
135
159
 
136
160
  private
137
161
 
162
+ # Track a cache key under its model type tag for targeted invalidation
163
+ def track_cache_key(query_key)
164
+ tag_key = "TAG:#{@model.model_class_name}"
165
+ existing_keys = @query_cache.key?(tag_key) ? @query_cache[tag_key] : []
166
+ unless existing_keys.include?(query_key)
167
+ existing_keys << query_key
168
+ @query_cache[tag_key] = existing_keys
169
+ end
170
+ end
171
+
138
172
  def model_construct?
139
173
  construct_name = @model.model_class_name.tableize.singularize rescue @model.class.name.tableize.singularize
140
174
  File.exist?("#{ConfigFile.path}/constructs/#{construct_name}.sparql")
@@ -208,6 +242,9 @@ order by ?s
208
242
  Solis::LOGGER.info("CACHE: to #{query_key}") if ConfigFile[:debug]
209
243
  end
210
244
 
245
+ # Always ensure the cache key is tracked under its model type tag
246
+ track_cache_key(query_key)
247
+
211
248
  result
212
249
  rescue StandardError => e
213
250
  Solis::LOGGER.error(e.message)
@@ -52,28 +52,20 @@ module Solis
52
52
  end
53
53
 
54
54
  def read_sheets(key, spreadsheet_id, options)
55
- data = nil
56
55
  prefixes = options[:prefixes] || nil
57
56
  metadata = options[:metadata] || nil
58
57
 
59
- cache_dir = ConfigFile.include?(:cache) ? ConfigFile[:cache] : '/tmp'
60
-
61
- if ::File.exist?("#{cache_dir}/#{spreadsheet_id}.json") && (options.include?(:from_cache) && options[:from_cache])
62
- Solis::LOGGER.info("from cache #{cache_dir}/#{spreadsheet_id}.json")
63
- data = JSON.parse(::File.read("#{cache_dir}/#{spreadsheet_id}.json"), { symbolize_names: true })
64
- return data
65
- else
66
- Solis::LOGGER.info("from source #{spreadsheet_id}")
67
- session = SimpleSheets.new(key, spreadsheet_id)
68
- session.key = key
69
- sheets = {}
70
- session.worksheets.each do |worksheet|
71
- sheet = ::Sheet.new(worksheet)
72
- sheets[sheet.title] = sheet
73
- end
74
-
75
- validate(sheets, prefixes, metadata)
58
+ Solis::LOGGER.info("from source #{spreadsheet_id}")
59
+ session = SimpleSheets.new(key, spreadsheet_id)
60
+ session.key = key
61
+ sheets = {}
62
+ session.worksheets.each do |worksheet|
63
+ sheet = ::Sheet.new(worksheet)
64
+ sheets[sheet.title] = sheet
76
65
  end
66
+
67
+ validate(sheets, prefixes, metadata)
68
+
77
69
  sheets
78
70
  end
79
71
 
@@ -118,6 +110,7 @@ module Solis
118
110
 
119
111
  entities.store(e['name'].to_sym, { description: e['description'],
120
112
  order: e['order'],
113
+ category: e['categories'],
121
114
  plural: e['nameplural'],
122
115
  label: e['name'].to_s.strip,
123
116
  sub_class_of: e['subclassof'].nil? || e['subclassof'].empty? ? [] : [e['subclassof']],
@@ -137,12 +130,6 @@ module Solis
137
130
  metadata: ontology_metadata
138
131
  }
139
132
 
140
- cache_dir = ConfigFile.include?(:cache) ? ConfigFile[:cache] : '/tmp'
141
- # ::File.open("#{::File.absolute_path(cache_dir)}/#{spreadsheet_id}.json", 'wb') do |f|
142
- ::File.open("#{::File.absolute_path(cache_dir)}/#{sheet_id}.json", 'wb') do |f|
143
- f.puts data.to_json
144
- end
145
-
146
133
  data
147
134
  rescue StandardError => e
148
135
  raise Solis::Error::GeneralError, e.message
@@ -180,6 +167,7 @@ module Solis
180
167
  cardinality: { min: min_max['min'], max: min_max['max'] },
181
168
  same_as: p['sameas'],
182
169
  order: p['order'],
170
+ category: p['categories'],
183
171
  description: p['description']
184
172
  }
185
173
 
@@ -302,6 +290,8 @@ hide empty members
302
290
  node = target_class
303
291
  end
304
292
 
293
+ groups = {}
294
+
305
295
  out += %(
306
296
  #{graph_prefix}:#{entity_name}Shape
307
297
  a #{shacl_prefix}:NodeShape ;
@@ -322,11 +312,19 @@ hide empty members
322
312
  min_count = property_metadata[:cardinality][:min].strip
323
313
  max_count = property_metadata[:cardinality][:max].strip
324
314
  order = property_metadata.key?(:order) && property_metadata[:order] ? property_metadata[:order]&.strip : nil
315
+ category = property_metadata.key?(:category) && property_metadata[:category] ? property_metadata[:category]&.strip : nil
316
+ category = category.nil? || category.empty? ? nil : category
317
+
318
+ unless category.nil?
319
+ group = "#{category.classify}Group"
320
+ groups[group] = category unless groups.key?(group)
321
+ end
325
322
 
326
323
  if datatype =~ /^#{graph_prefix}:/ || datatype =~ /^<#{graph_name}/
327
324
  out += %( #{shacl_prefix}:property [#{shacl_prefix}:path #{path} ;
328
325
  #{shacl_prefix}:name "#{attribute}" ;
329
326
  #{shacl_prefix}:description "#{description}" ;#{order.nil? ? '' : "\n #{shacl_prefix}:order #{order} ;"}
327
+ #{category.nil? ? '' : "\n #{shacl_prefix}:group #{graph_prefix}:#{group} ;"}
330
328
  #{shacl_prefix}:nodeKind #{shacl_prefix}:IRI ;
331
329
  #{shacl_prefix}:class #{datatype} ;#{min_count =~ /\d+/ ? "\n #{shacl_prefix}:minCount #{min_count} ;" : ''}#{max_count =~ /\d+/ ? "\n #{shacl_prefix}:maxCount #{max_count} ;" : ''}
332
330
  ] ;
@@ -344,6 +342,7 @@ hide empty members
344
342
  out += %( #{shacl_prefix}:property [#{shacl_prefix}:path #{path} ;
345
343
  #{shacl_prefix}:name "#{attribute}";
346
344
  #{shacl_prefix}:description "#{description}" ;#{order.nil? ? '' : "\n #{shacl_prefix}:order #{order} ;"}
345
+ #{category.nil? ? '' : "\n #{shacl_prefix}:group #{graph_prefix}:#{group} ;"}
347
346
  #{shacl_prefix}:datatype #{datatype} ;#{min_count =~ /\d+/ ? "\n #{shacl_prefix}:minCount #{min_count} ;" : ''}#{max_count =~ /\d+/ ? "\n #{shacl_prefix}:maxCount #{max_count} ;" : ''}
348
347
  ] ;
349
348
  )
@@ -359,6 +358,15 @@ hide empty members
359
358
  end
360
359
  end
361
360
  out += ".\n"
361
+
362
+ groups.each do |group, category|
363
+ out += %(
364
+ #{graph_prefix}:#{group}
365
+ a sh:PropertyGroup ;
366
+ rdfs:label "#{category}" .
367
+
368
+ )
369
+ end
362
370
  end
363
371
  end
364
372
  out
@@ -716,6 +724,14 @@ hide empty members
716
724
  end
717
725
  end
718
726
 
727
+ cache_dir = ConfigFile.include?(:cache) ? ConfigFile[:cache] : '/tmp'
728
+ cache_file = "#{::File.absolute_path(cache_dir)}/#{spreadsheet_id}.json"
729
+
730
+ if options[:from_cache] && ::File.exist?(cache_file)
731
+ Solis::LOGGER.info("Loading cached result from #{cache_file}")
732
+ return JSON.parse(::File.read(cache_file), symbolize_names: true)
733
+ end
734
+
719
735
  sheet_data = read_sheets(key, spreadsheet_id, options)
720
736
  prefixes = sheet_data['_PREFIXES']
721
737
  metadata = sheet_data['_METADATA']
@@ -736,10 +752,6 @@ hide empty members
736
752
  { sheet_url: reference['sheeturl'], description: reference['description'] }
737
753
  end
738
754
 
739
- cache_dir = ConfigFile.include?(:cache) ? ConfigFile[:cache] : '/tmp'
740
- ::File.open("#{::File.absolute_path(cache_dir)}/#{spreadsheet_id}.json", 'wb') do |f|
741
- f.puts references.to_json
742
- end
743
755
  else
744
756
  references = sheet_data
745
757
  end
@@ -778,7 +790,16 @@ hide empty members
778
790
  Solis::LOGGER.info('Generating JSON SCHEMA')
779
791
  json_schema = build_json_schema(shacl)
780
792
 
781
- { inflections: inflections, shacl: shacl, schema: schema, plantuml: plantuml, json_schema: json_schema }
793
+ result = { inflections: inflections, shacl: shacl, schema: schema, plantuml: plantuml, json_schema: json_schema }
794
+
795
+ begin
796
+ ::File.open(cache_file, 'wb') { |f| f.puts result.to_json }
797
+ Solis::LOGGER.info("Cached result to #{cache_file}")
798
+ rescue StandardError => e
799
+ Solis::LOGGER.warn("Failed to write cache: #{e.message}")
800
+ end
801
+
802
+ result
782
803
  end
783
804
  end
784
805
  end
data/lib/solis/shape.rb CHANGED
@@ -104,6 +104,7 @@ module Solis
104
104
  attribute_class = solution.attributeClass if solution.bound?(:attributeClass)
105
105
  attribute_comment = solution.attributeComment if solution.bound?(:attributeComment)
106
106
  attribute_order = solution.attributeOrder if solution.bound?(:attributeOrder)
107
+ attribute_group = solution.attributeGroup if solution.bound?(:attributeGroup)
107
108
 
108
109
  attribute_max_count = 1 if solution.bound?(:attributeUniqueLang) && solution.attributeUniqueLang.value.eql?('true')
109
110
 
@@ -125,6 +126,7 @@ module Solis
125
126
  mincount: attribute_min_count,
126
127
  maxcount: attribute_max_count,
127
128
  order: attribute_order,
129
+ group: attribute_group,
128
130
  node: attribute_node,
129
131
  node_kind: attribute_node_kind,
130
132
  class: attribute_class,
@@ -142,7 +144,7 @@ PREFIX rdfv: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
142
144
 
143
145
  SELECT ?targetClass ?targetNode ?comment ?className ?attributePath ?attributeName ?attributeDatatype
144
146
  ?attributeMinCount ?attributeMaxCount ?attributeOr ?attributeClass
145
- ?attributeNode ?attributeNodeKind ?attributeComment ?attributeOrder ?attributeUniqueLang ?o
147
+ ?attributeNode ?attributeNodeKind ?attributeComment ?attributeOrder ?attributeGroup ?attributeUniqueLang ?o
146
148
  WHERE {
147
149
 
148
150
  ?s a sh:NodeShape;
@@ -162,6 +164,7 @@ WHERE {
162
164
  OPTIONAL{ ?attributes sh:node ?attributeNode } .
163
165
  OPTIONAL{ ?attributes sh:description ?attributeComment } .
164
166
  OPTIONAL{ ?attributes sh:order ?attributeOrder } .
167
+ OPTIONAL{ ?attributes sh:group ?attributeGroup } .
165
168
  OPTIONAL{ ?attributes sh:uniqueLang ?attributeUniqueLang } .
166
169
  }.
167
170
  }
@@ -182,6 +185,7 @@ WHERE {
182
185
  "mincount": 1,
183
186
  "maxcount": 1,
184
187
  "order": nil,
188
+ "group": nil,
185
189
  "node": nil,
186
190
  "node_kind": nil,
187
191
  "class": nil,
data/lib/solis/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Solis
2
- VERSION = "0.115.0"
2
+ VERSION = "0.117.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.115.0
4
+ version: 0.117.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehmet Celik