solis 0.114.0 → 0.116.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: 0c79cee8453b41aeab243fc45e69132a9192f9bc3481b447e68db95895d8e1e2
4
- data.tar.gz: 1afd13dbd1e5e95505f5363766c829730e88d56e3e89471b8f770044f4e8b6e8
3
+ metadata.gz: affbb3891268851909a6a35684c11a36f6e67d617dea1e977015124ca0bd8118
4
+ data.tar.gz: 920ad82dc52d0bdefe3bd1e08b631e6907d6f13952f5573fc3bb8f392b03521d
5
5
  SHA512:
6
- metadata.gz: 1e8fc91acdcf2f17f029907895be2ea35b3e0813aca875dc6f46f3787907ec7a02b66c8c46d012eb4e7f65e1920035e9eb57d576751c474ac6078c73963ee3aa
7
- data.tar.gz: 1ba7210919daa84eed4be4994515834c0561f5d22f1d03ddb18f78cffb499be1f0c400946f10f5f8792437098f6cd204cb8ad0741bbf93aa9f55f54156d4c022
6
+ metadata.gz: 9b42751289ba605e41d7e02c85a6a7df99a41a80237ca282a4502d3c4a48198ba8057a35b884a51914b828657a88b1d6c3899d3dd703f10c30a0b9ff779dd364
7
+ data.tar.gz: 8d627312e95e719ab5ddabbd8bdbd0f964aa0a98ed6372df07f18462ecf69a9bdd4ce1e53f21b29392ad450a5874ebfb6bc5b510bc2adbb5fe634a1edf325cc0
data/README.md CHANGED
@@ -4,7 +4,7 @@ Solis means 'the sun' in Latin or just Silos spelled backwards.
4
4
  A simple idea to use SHACL to describe models and API on top of a data store.
5
5
 
6
6
  # Status
7
- Although I'm using it in production I must shamefully acknowledge that it actually needs refactoring. Solis is currently still grown organically driven by my needs and understanding of problem domain(RDF, triples, ...).
7
+ Although I'm using it in production I must shamefully acknowledge that it actually needs refactoring. Solis is currently still grown organically driven by my needs and understanding of the problem domain(RDF, triples, ...).
8
8
  When my current project is live I'll start to refactor the code and release it as 1.0.
9
9
 
10
10
  # From Google Sheets to RDF, Shacl, ... to API
@@ -422,6 +422,88 @@ Solis::ConfigFile.path = './'
422
422
  solis = Solis::Graph.new(Solis::Shape::Reader::File.read(Solis::ConfigFile[:solis][:shacl]), Solis::ConfigFile[:solis][:env])
423
423
  ```
424
424
 
425
+ ## Performance Optimizations
426
+
427
+ Entity create/update operations have been optimized to reduce SPARQL round-trips from ~25+ down to ~5-7 for a typical update with embedded entities.
428
+
429
+ ### What changed
430
+
431
+ | Optimization | Impact |
432
+ |---|---|
433
+ | **Cached `up?` health check** (30s TTL) | Eliminates ~10 redundant ASK queries per operation |
434
+ | **Removed UUID collision check** | Saves 2 round-trips per new entity (UUID v4 collision is ~1 in 2^122) |
435
+ | **Eliminated redundant re-fetches in `update()`** | Saves 1-3 SELECT queries by returning in-memory data |
436
+ | **`known_entities` cache in graph building** | Avoids re-fetching entities from store during `build_ttl_objekt` recursion |
437
+ | **Batched existence checks** (`batch_exists?`) | Replaces N individual ASK queries with 1 SELECT for embedded entities |
438
+ | **Batched orphan reference checks** (`batch_referenced?`) | Replaces N individual ASK queries with 1 SELECT for orphan detection |
439
+ | **SPARQL client reuse** | `save()` passes its client to `update()` when delegating |
440
+
441
+ ## PATCH vs PUT Updates
442
+
443
+ The `update()` method supports both PUT (full replace) and PATCH (partial merge) semantics via the `patch:` keyword argument.
444
+
445
+ ```ruby
446
+ # PUT (default) — replaces embedded arrays entirely, orphans removed entities
447
+ entity.update({ 'id' => '1', 'students' => [{ 'id' => 's1' }] })
448
+
449
+ # PATCH — merges into existing arrays, no orphan deletion
450
+ entity.update({ 'id' => '1', 'students' => [{ 'id' => 's3' }] }, true, true, nil, patch: true)
451
+ ```
452
+
453
+ ### PUT mode (default, `patch: false`)
454
+ - Embedded entity arrays are **fully replaced** by the provided data
455
+ - Entities removed from the array are detected as orphans and may be deleted
456
+ - Omitted attributes keep their original values
457
+
458
+ ### PATCH mode (`patch: true`)
459
+ - Embedded entity arrays are **merged**: new IDs are added, existing IDs are updated, unmentioned IDs are kept
460
+ - **No orphan detection or deletion** — removing an entity requires an explicit PUT or destroy
461
+ - Omitted embedded attributes are left completely untouched
462
+
463
+ ## Orphan Protection (Embedded Entity Lifecycle)
464
+
465
+ When updating an entity with PUT semantics and removing a reference to an embedded entity, Solis decides whether to delete the orphaned entity using this decision chain:
466
+
467
+ | # | Check | Result |
468
+ |---|-------|--------|
469
+ | 1 | Entity in `embedded_readonly`? | **Never delete** (code tables, lookup values) |
470
+ | 2 | Top-level entity + NOT in `embedded_delete`? | **Unlink only** (safe default) |
471
+ | 3 | Top-level entity + in `embedded_delete`? | **Delete** (opt-in cascade) |
472
+ | 4 | Still referenced by other entities? | **Never delete** (safety net) |
473
+
474
+ A **top-level entity** is one that has its own `sh:NodeShape` + `sh:targetClass` in the SHACL schema — it is independently addressable with its own CRUD endpoint.
475
+
476
+ ### Configuration
477
+
478
+ ```yaml
479
+ :solis:
480
+ # Code tables / lookup values: never modified, never deleted
481
+ :embedded_readonly:
482
+ - :Codetable
483
+
484
+ # Child/join entities that should cascade-delete when orphaned
485
+ :embedded_delete:
486
+ - :About
487
+ - :Contributor
488
+ - :Comment
489
+ - :Link
490
+ - :ImageObject
491
+ - :Identifier
492
+ - :Provider
493
+ ```
494
+
495
+ ### Example
496
+
497
+ Given an Item with 5 About entities (join between Item and Tag+Role):
498
+
499
+ ```ruby
500
+ # PUT: reduce from 5 Abouts to 1 — the other 4 are deleted (About is in embedded_delete)
501
+ item.update({ 'id' => '1', 'about' => [{ 'id' => 'a1' }] })
502
+
503
+ # But if Item references a Tag directly, removing it just unlinks — Tag is top-level, not in embedded_delete
504
+ ```
505
+
506
+ Entities like `Tag`, `Agens`, `Person`, `Organization`, `Owner`, `Book` are protected automatically because they are top-level entities not listed in `embedded_delete`.
425
507
 
426
508
  TODO:
427
509
  - extract sparql layer into its own gem
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
@@ -1,6 +1,7 @@
1
1
  require 'securerandom'
2
2
  require 'iso8601'
3
3
  require 'hashdiff'
4
+ require 'set'
4
5
  require_relative 'query'
5
6
 
6
7
  module Solis
@@ -139,6 +140,9 @@ values ?s {<#{self.graph_id}>}
139
140
  end
140
141
  end
141
142
 
143
+ # Invalidate cached queries for this entity type
144
+ Solis::Query.invalidate_cache_for(self.class.name)
145
+
142
146
  result
143
147
  end
144
148
 
@@ -150,44 +154,71 @@ values ?s {<#{self.graph_id}>}
150
154
 
151
155
  if self.exists?(sparql)
152
156
  data = properties_to_hash(self)
153
- result = update(data)
157
+ result = update(data, validate_dependencies, top_level, sparql)
154
158
  else
155
159
  data = properties_to_hash(self)
156
160
  attributes = data.include?('attributes') ? data['attributes'] : data
157
161
  readonly_list = (Solis::Options.instance.get[:embedded_readonly] || []).map(&:to_s)
158
162
 
163
+ # Collect all embedded entities first for batched existence check
164
+ all_embedded = []
159
165
  attributes.each_pair do |key, value|
160
166
  unless self.class.metadata[:attributes][key][:node].nil?
161
167
  value = [value] unless value.is_a?(Array)
162
168
  value.each do |sub_value|
163
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
164
174
 
165
- if readonly_entity?(embedded, readonly_list)
166
- # Readonly entities (code tables) should never be modified
167
- # Only verify they exist, do not create or update them
168
- unless embedded.exists?(sparql)
169
- Solis::LOGGER.warn("#{embedded.class.name} (id: #{embedded.id}) is readonly but does not exist in database. Skipping.")
170
- end
171
- else
172
- # Non-readonly entities can be created or updated
173
- if embedded.exists?(sparql)
174
- embedded_data = properties_to_hash(embedded)
175
- embedded.update(embedded_data, validate_dependencies, false)
176
- else
177
- embedded.save(validate_dependencies, false)
178
- end
179
- end
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.")
180
188
  end
189
+ elsif entity_exists
190
+ to_update_embedded << embedded
191
+ else
192
+ to_create_embedded << embedded
181
193
  end
182
194
  end
183
195
 
184
- 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)
185
213
 
186
214
  Solis::LOGGER.info SPARQL::Client::Update::InsertData.new(graph, graph: graph.name).to_s if ConfigFile[:debug]
187
215
 
188
216
  result = sparql.insert_data(graph, graph: graph.name)
189
217
  end
190
218
 
219
+ # Invalidate cached queries for this entity type
220
+ Solis::Query.invalidate_cache_for(self.class.name)
221
+
191
222
  after_create_proc&.call(self)
192
223
  self
193
224
  rescue StandardError => e
@@ -195,79 +226,132 @@ values ?s {<#{self.graph_id}>}
195
226
  raise e
196
227
  end
197
228
 
198
- def update(data, validate_dependencies = true, top_level = true)
229
+ # Update an entity.
230
+ #
231
+ # @param data [Hash] the attributes to update. Must include 'id'.
232
+ # @param validate_dependencies [Boolean] whether to validate dependencies (default: true)
233
+ # @param top_level [Boolean] whether this is a top-level call (default: true)
234
+ # @param sparql_client [SPARQL::Client, nil] optional reusable SPARQL client
235
+ # @param patch [Boolean] when true, uses PATCH semantics:
236
+ # - Only provided attributes are changed; omitted attributes are untouched
237
+ # - Embedded entity arrays are merged (new IDs added, existing IDs updated,
238
+ # missing IDs are kept as-is — not orphaned)
239
+ # - No orphan detection or deletion
240
+ # When false (default), uses PUT semantics:
241
+ # - Embedded entity arrays are fully replaced
242
+ # - Entities removed from the array are orphaned and deleted if unreferenced
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)
199
245
  raise Solis::Error::GeneralError, "I need a SPARQL endpoint" if self.class.sparql_endpoint.nil?
200
246
 
201
247
  attributes = data.include?('attributes') ? data['attributes'] : data
202
248
  raise "id is mandatory when updating" unless attributes.keys.include?('id')
203
249
 
204
250
  id = attributes.delete('id')
205
- sparql = SPARQL::Client.new(self.class.sparql_endpoint)
251
+ sparql = sparql_client || SPARQL::Client.new(self.class.sparql_endpoint)
206
252
 
207
- 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
208
254
  raise Solis::Error::NotFoundError if original_klass.nil?
209
255
  updated_klass = original_klass.deep_dup
210
256
 
211
257
  # Cache readonly entities list once
212
258
  readonly_list = (Solis::Options.instance.get[:embedded_readonly] || []).map(&:to_s)
213
259
 
214
- # Track entities to potentially delete
260
+ # Track entities to potentially delete (only used in PUT mode)
215
261
  entities_to_check_for_deletion = {}
216
262
 
263
+ # First pass: collect all embedded entities for batched existence check
264
+ embedded_by_key = {}
217
265
  attributes.each_pair do |key, value|
218
266
  unless original_klass.class.metadata[:attributes][key][:node].nil?
219
267
  value = [value] unless value.is_a?(Array)
268
+ embedded_by_key[key] = value.map do |sub_value|
269
+ self.class.graph.shape_as_model(original_klass.class.metadata[:attributes][key][:datatype].to_s).new(sub_value)
270
+ end
271
+ end
272
+ end
220
273
 
221
- # Get original embedded entities for this attribute
222
- original_embedded = original_klass.instance_variable_get("@#{key}")
223
- original_embedded = [original_embedded] unless original_embedded.nil? || original_embedded.is_a?(Array)
224
- original_embedded ||= []
274
+ all_embedded = embedded_by_key.values.flatten
275
+ existing_ids = self.class.batch_exists?(sparql, all_embedded)
225
276
 
226
- # Track original IDs
227
- original_ids = original_embedded.map { |e| solis_model?(e) ? e.id : nil }.compact
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
228
286
 
229
- # Build new array of embedded entities
230
- new_embedded_values = []
231
- new_ids = []
287
+ # Second pass: process embedded entities using batched results
288
+ embedded_by_key.each do |key, embedded_list|
289
+ value = attributes[key]
290
+ value = [value] unless value.is_a?(Array)
232
291
 
233
- value.each do |sub_value|
234
- embedded = self.class.graph.shape_as_model(original_klass.class.metadata[:attributes][key][:datatype].to_s).new(sub_value)
235
- new_ids << embedded.id if embedded.id
292
+ # Get original embedded entities for this attribute
293
+ original_embedded = original_klass.instance_variable_get("@#{key}")
294
+ original_embedded = [original_embedded] unless original_embedded.nil? || original_embedded.is_a?(Array)
295
+ original_embedded ||= []
236
296
 
237
- if readonly_entity?(embedded, readonly_list)
238
- # Readonly entities (code tables) should never be modified
239
- # Only verify they exist, do not create or update them
240
- if embedded.exists?(sparql)
241
- new_embedded_values << embedded
242
- else
243
- Solis::LOGGER.warn("#{embedded.class.name} (id: #{embedded.id}) is readonly but does not exist in database. Skipping.")
244
- end
297
+ # Track original IDs
298
+ original_ids = original_embedded.map { |e| solis_model?(e) ? e.id : nil }.compact
299
+
300
+ # Build new array of embedded entities
301
+ new_embedded_values = []
302
+ new_ids = []
303
+
304
+ embedded_list.each do |embedded|
305
+ new_ids << embedded.id if embedded.id
306
+ entity_exists = existing_ids.include?(embedded.graph_id)
307
+
308
+ if readonly_entity?(embedded, readonly_list)
309
+ if entity_exists
310
+ new_embedded_values << embedded
245
311
  else
246
- # Non-readonly entities can be created or updated
247
- if embedded.exists?(sparql)
248
- embedded_data = properties_to_hash(embedded)
249
- embedded.update(embedded_data, validate_dependencies, false)
250
- new_embedded_values << embedded
251
- else
252
- embedded_value = embedded.save(validate_dependencies, false)
253
- new_embedded_values << embedded_value
254
- end
312
+ Solis::LOGGER.warn("#{embedded.class.name} (id: #{embedded.id}) is readonly but does not exist in database. Skipping.")
313
+ end
314
+ else
315
+ if entity_exists
316
+ 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)
320
+ new_embedded_values << embedded
321
+ else
322
+ embedded_value = embedded.save(validate_dependencies, false)
323
+ new_embedded_values << embedded_value
255
324
  end
256
325
  end
326
+ end
327
+
328
+ if patch
329
+ # PATCH mode: merge new embedded entities into the original array.
330
+ # Keep original entities that were not mentioned in the update data.
331
+ unmentioned = original_embedded.select do |e|
332
+ solis_model?(e) && !new_ids.include?(e.id)
333
+ end
334
+ merged_values = unmentioned + new_embedded_values
335
+ else
336
+ # PUT mode: replace the entire array; detect orphans for deletion
337
+ merged_values = new_embedded_values
257
338
 
258
- # Identify orphaned entities (in original but not in new)
259
- # Note: Readonly entities will be filtered out in delete_orphaned_entities
260
339
  orphaned_ids = original_ids - new_ids
261
340
  unless orphaned_ids.empty?
262
341
  orphaned_entities = original_embedded.select { |e| solis_model?(e) && orphaned_ids.include?(e.id) }
263
342
  entities_to_check_for_deletion[key] = orphaned_entities
264
343
  end
265
-
266
- # Replace entire array with new values
267
- maxcount = original_klass.class.metadata[:attributes][key][:maxcount]
268
- value = maxcount && maxcount == 1 ? new_embedded_values.first : new_embedded_values
269
344
  end
270
345
 
346
+ maxcount = original_klass.class.metadata[:attributes][key][:maxcount]
347
+ embedded_value = maxcount && maxcount == 1 ? merged_values.first : merged_values
348
+ updated_klass.instance_variable_set("@#{key}", embedded_value)
349
+ end
350
+
351
+ # Process non-embedded attributes
352
+ attributes.each_pair do |key, value|
353
+ next unless original_klass.class.metadata[:attributes][key][:node].nil?
354
+
271
355
  updated_klass.instance_variable_set("@#{key}", value)
272
356
  end
273
357
 
@@ -278,9 +362,12 @@ values ?s {<#{self.graph_id}>}
278
362
 
279
363
  if Hashdiff.best_diff(properties_original_klass, properties_updated_klass).empty?
280
364
  Solis::LOGGER.info("#{original_klass.class.name} unchanged, skipping")
281
- data = self.query.filter({ filters: { id: [id] } }).find_all.map { |m| m }&.first
365
+ data = original_klass
282
366
  else
283
- delete_graph = as_graph(original_klass, false)
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)
284
371
  where_graph = RDF::Graph.new(graph_name: RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), data: RDF::Repository.new)
285
372
 
286
373
  if id.is_a?(Array)
@@ -291,23 +378,31 @@ values ?s {<#{self.graph_id}>}
291
378
  where_graph << [RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), :p, :o]
292
379
  end
293
380
 
294
- insert_graph = as_graph(updated_klass, true)
381
+ insert_known = collect_known_entities(updated_klass)
382
+ insert_graph = as_graph(updated_klass, true, insert_known)
295
383
 
296
384
  delete_insert_query = SPARQL::Client::Update::DeleteInsert.new(delete_graph, insert_graph, where_graph, graph: insert_graph.name).to_s
297
385
  delete_insert_query.gsub!('_:p', '?p')
298
386
 
299
- data = sparql.query(delete_insert_query)
387
+ sparql.query(delete_insert_query)
388
+
389
+ # Invalidate cache before verification to avoid stale reads
390
+ Solis::Query.invalidate_cache_for(self.class.name)
300
391
 
392
+ # Verify the update succeeded by re-fetching; fallback to insert if needed
301
393
  data = self.query.filter({ filters: { id: [id] } }).find_all.map { |m| m }&.first
302
394
  if data.nil?
303
395
  sparql.insert_data(insert_graph, graph: insert_graph.name)
304
- data = self.query.filter({ filters: { id: [id] } }).find_all.map { |m| m }&.first
396
+ data = updated_klass
305
397
  end
306
398
 
307
- # Delete orphaned entities after successful update
308
- delete_orphaned_entities(entities_to_check_for_deletion, sparql)
399
+ # Delete orphaned entities after successful update (PUT mode only)
400
+ delete_orphaned_entities(entities_to_check_for_deletion, sparql) unless patch
309
401
  end
310
402
 
403
+ # Invalidate cached queries for this entity type
404
+ Solis::Query.invalidate_cache_for(self.class.name)
405
+
311
406
  after_update_proc&.call(updated_klass, data)
312
407
 
313
408
  data
@@ -333,18 +428,83 @@ values ?s {<#{self.graph_id}>}
333
428
  sparql.query("ASK WHERE { <#{self.graph_id}> ?p ?o }")
334
429
  end
335
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
+
490
+ # Check existence of multiple entities in a single SPARQL query
491
+ # Returns a Set of graph_ids that exist
492
+ def self.batch_exists?(sparql, entities)
493
+ return Set.new if entities.empty?
494
+ return Set.new([entities.first.graph_id]) if entities.size == 1 && entities.first.exists?(sparql)
495
+ return Set.new if entities.size == 1
496
+
497
+ values = entities.map { |e| "<#{e.graph_id}>" }.join(' ')
498
+ query = "SELECT DISTINCT ?s WHERE { VALUES ?s { #{values} } . ?s ?p ?o }"
499
+ results = sparql.query(query)
500
+ Set.new(results.map { |r| r[:s].to_s })
501
+ end
502
+
336
503
  def self.make_id_for(model)
337
- raise "I need a SPARQL endpoint" if self.sparql_endpoint.nil?
338
- sparql = Solis::Store::Sparql::Client.new(self.sparql_endpoint)
339
504
  id = model.instance_variable_get("@id")
340
505
  if id.nil? || (id.is_a?(String) && id&.empty?)
341
- id_retries = 0
342
-
343
- while id.nil? || sparql.query("ASK WHERE { ?s <#{self.graph_name}id> \"#{id}\" }")
344
- id = SecureRandom.uuid
345
- id_retries += 1
346
- end
347
- LOGGER.info("ID(#{id}) generated for #{self.name} in #{id_retries} retries") if ConfigFile[:debug]
506
+ id = SecureRandom.uuid
507
+ LOGGER.info("ID(#{id}) generated for #{self.name}") if ConfigFile[:debug]
348
508
  model.instance_variable_set("@id", id)
349
509
  elsif id.to_s =~ /^https?:\/\//
350
510
  id = id.to_s.split('/').last
@@ -414,20 +574,22 @@ values ?s {<#{self.graph_id}>}
414
574
  end
415
575
 
416
576
  def self.model(level = 0)
417
- m = { type: self.name.tableize, attributes: {} }
577
+ m = { type: self.name.tableize, attributes: [] }
418
578
  self.metadata[:attributes].each do |attribute, attribute_metadata|
419
- is_array = ((attribute_metadata[:maxcount].nil? || (attribute_metadata[:maxcount].to_i > 1)) && !attribute_metadata[:datatype].eql?(:lang_string))
420
- attribute_name = is_array ? "#{attribute}[]" : attribute
421
- attribute_name = attribute_metadata[:mincount].to_i > 0 ? "#{attribute_name}*" : attribute_name
422
579
  if attribute_metadata.key?(:class) && !attribute_metadata[:class].nil? && attribute_metadata[:class].value =~ /#{self.graph_name}/ && level == 0
423
580
  cm = self.graph.shape_as_model(self.metadata[:attributes][attribute][:datatype].to_s).model(level + 1)
424
- m[:attributes][attribute_name.to_sym] = cm[:attributes]
425
- else
426
- m[:attributes][attribute_name.to_sym] = { description: attribute_metadata[:comment]&.value,
427
- mandatory: (attribute_metadata[:mincount].to_i > 0),
428
- data_type: attribute_metadata[:datatype] }
429
- m[:attributes][attribute_name.to_sym][:order] = attribute_metadata[:order]&.value.to_i if attribute_metadata.key?(:order) && !attribute_metadata[:order].nil?
430
581
  end
582
+
583
+ attribute_data = { name: attribute,
584
+ data_type: attribute_metadata[:datatype],
585
+ mandatory: (attribute_metadata[:mincount].to_i > 0),
586
+ description: attribute_metadata[:comment]&.value
587
+ }
588
+ attribute_data[:order] = attribute_metadata[:order]&.value.to_i if attribute_metadata.key?(:order) && !attribute_metadata[:order].nil?
589
+ attribute_data[:category] = attribute_metadata[:category]&.value if attribute_metadata.key?(:category) && !attribute_metadata[:category].nil?
590
+ attribute_data[:attributes] = cm[:attributes] if cm && cm[:attributes]
591
+
592
+ m[:attributes] << attribute_data
431
593
  end
432
594
 
433
595
  m
@@ -486,6 +648,21 @@ values ?s {<#{self.graph_id}>}
486
648
 
487
649
  private
488
650
 
651
+ # Walk the entity tree and collect all in-memory entities by UUID.
652
+ # Prevents redundant store fetches during recursive graph building.
653
+ def collect_known_entities(entity, collected = {})
654
+ uuid = entity.instance_variable_get("@id")
655
+ return collected if uuid.nil? || collected.key?(uuid)
656
+ collected[uuid] = entity
657
+ entity.class.metadata[:attributes].each do |attr, meta|
658
+ next if meta[:node_kind].nil?
659
+ val = entity.instance_variable_get("@#{attr}")
660
+ next if val.nil?
661
+ Array(val).each { |v| collect_known_entities(v, collected) if solis_model?(v) }
662
+ end
663
+ collected
664
+ end
665
+
489
666
  # Helper method to check if an object is a Solis model
490
667
  def solis_model?(obj)
491
668
  obj.class.ancestors.include?(Solis::Model)
@@ -497,6 +674,13 @@ values ?s {<#{self.graph_id}>}
497
674
  (entity.class.ancestors.map(&:to_s) & readonly_list).any?
498
675
  end
499
676
 
677
+ # Helper method to check if an entity is a top-level entity (has its own shape definition).
678
+ # Top-level entities are independently addressable and should not be cascade-deleted
679
+ # when unlinked from a parent, unless explicitly opted in via embedded_delete config.
680
+ def top_level_entity?(entity)
681
+ self.class.graph.shape?(entity.class.name)
682
+ end
683
+
500
684
  # Helper method to get tableized class name
501
685
  def tableized_class_name(obj)
502
686
  obj.class.name.tableize
@@ -514,50 +698,85 @@ values ?s {<#{self.graph_id}>}
514
698
  RDF::URI("#{self.class.graph_name}#{class_name.tableize}/#{id}")
515
699
  end
516
700
 
517
- # Delete orphaned entities that are no longer referenced
701
+ # Delete orphaned entities that are no longer referenced.
702
+ #
703
+ # Decision logic (in order of precedence):
704
+ # 1. embedded_readonly → never delete (code tables)
705
+ # 2. Top-level entity (has own shape) + NOT in embedded_delete → unlink only, don't delete
706
+ # 3. Top-level entity + listed in embedded_delete → delete (opt-in override)
707
+ # 4. Still referenced by other entities (batch_referenced?) → never delete (safety net)
518
708
  def delete_orphaned_entities(entities_to_check, sparql)
519
709
  return if entities_to_check.nil? || entities_to_check.empty?
520
710
 
521
711
  readonly_list = (Solis::Options.instance.get[:embedded_readonly] || []).map(&:to_s)
712
+ delete_list = (Solis::Options.instance.get[:embedded_delete] || []).map(&:to_s)
522
713
 
523
- entities_to_check.each do |key, orphaned_entities|
714
+ # Collect all deletable orphans
715
+ deletable_orphans = []
716
+ entities_to_check.each do |_key, orphaned_entities|
524
717
  next if orphaned_entities.nil?
525
-
526
718
  orphaned_entities.each do |orphaned_entity|
527
719
  next unless solis_model?(orphaned_entity)
528
720
 
529
- # Skip if it's a readonly entity (like code tables)
721
+ # 1. Never delete readonly entities (code tables)
530
722
  if readonly_entity?(orphaned_entity, readonly_list)
531
723
  Solis::LOGGER.info("#{orphaned_entity.class.name} (id: #{orphaned_entity.id}) is in embedded_readonly list. Skipping deletion.")
532
724
  next
533
725
  end
534
726
 
535
- # Check if the entity is still referenced elsewhere
536
- if orphaned_entity.is_referenced?(sparql)
537
- Solis::LOGGER.info("#{orphaned_entity.class.name} (id: #{orphaned_entity.id}) is still referenced elsewhere. Skipping deletion.")
538
- next
727
+ # 2. Top-level entities are never auto-deleted unless opted in via embedded_delete
728
+ if top_level_entity?(orphaned_entity)
729
+ explicitly_deletable = (orphaned_entity.class.ancestors.map(&:to_s) & delete_list).any?
730
+ unless explicitly_deletable
731
+ Solis::LOGGER.info("#{orphaned_entity.class.name} (id: #{orphaned_entity.id}) is a top-level entity. Skipping deletion (unlink only).")
732
+ next
733
+ end
539
734
  end
540
735
 
541
- # Safe to delete the orphan
542
- begin
543
- Solis::LOGGER.info("Deleting orphaned entity: #{orphaned_entity.class.name} (id: #{orphaned_entity.id})")
544
- orphaned_entity.destroy
545
- rescue StandardError => e
546
- Solis::LOGGER.error("Failed to delete orphaned entity #{orphaned_entity.class.name} (id: #{orphaned_entity.id}): #{e.message}")
547
- end
736
+ deletable_orphans << orphaned_entity
737
+ end
738
+ end
739
+
740
+ return if deletable_orphans.empty?
741
+
742
+ # 4. Safety net: batch check which orphans are still referenced elsewhere
743
+ referenced_ids = batch_referenced?(sparql, deletable_orphans)
744
+
745
+ deletable_orphans.each do |orphaned_entity|
746
+ if referenced_ids.include?(orphaned_entity.graph_id)
747
+ Solis::LOGGER.info("#{orphaned_entity.class.name} (id: #{orphaned_entity.id}) is still referenced elsewhere. Skipping deletion.")
748
+ next
749
+ end
750
+
751
+ begin
752
+ Solis::LOGGER.info("Deleting orphaned entity: #{orphaned_entity.class.name} (id: #{orphaned_entity.id})")
753
+ orphaned_entity.destroy
754
+ rescue StandardError => e
755
+ Solis::LOGGER.error("Failed to delete orphaned entity #{orphaned_entity.class.name} (id: #{orphaned_entity.id}): #{e.message}")
548
756
  end
549
757
  end
550
758
  end
551
759
 
552
- def as_graph(klass = self, resolve_all = true)
760
+ # Batch check which entities are still referenced by other entities
761
+ # Returns a Set of graph_ids that are referenced
762
+ def batch_referenced?(sparql, entities)
763
+ return Set.new if entities.empty?
764
+
765
+ values = entities.map { |e| "<#{e.graph_id}>" }.join(' ')
766
+ query = "SELECT DISTINCT ?o WHERE { VALUES ?o { #{values} } . ?s ?p ?o . FILTER(!CONTAINS(STR(?s), 'audit') && !CONTAINS(STR(?p), 'audit')) }"
767
+ results = sparql.query(query)
768
+ Set.new(results.map { |r| r[:o].to_s })
769
+ end
770
+
771
+ def as_graph(klass = self, resolve_all = true, known_entities = {}, skip_store_fetch: false)
553
772
  graph = RDF::Graph.new
554
773
  graph.name = RDF::URI(self.class.graph_name)
555
- id = build_ttl_objekt(graph, klass, [], resolve_all)
774
+ id = build_ttl_objekt(graph, klass, [], resolve_all, known_entities, skip_store_fetch: skip_store_fetch)
556
775
 
557
776
  graph
558
777
  end
559
778
 
560
- def build_ttl_objekt(graph, klass, hierarchy = [], resolve_all = true)
779
+ def build_ttl_objekt(graph, klass, hierarchy = [], resolve_all = true, known_entities = {}, skip_store_fetch: false)
561
780
  hierarchy.push("#{klass.class.name}(#{klass.instance_variables.include?(:@id) ? klass.instance_variable_get("@id") : ''})")
562
781
 
563
782
  graph_name = self.class.graph_name
@@ -568,8 +787,11 @@ values ?s {<#{self.graph_id}>}
568
787
 
569
788
  graph << [id, RDF::RDFV.type, klass_metadata[:target_class]]
570
789
 
571
- # load existing object and overwrite
572
- original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
790
+ # Use cached entity if available, otherwise query the store (unless skip_store_fetch)
791
+ original_klass = known_entities[uuid]
792
+ if original_klass.nil? && !skip_store_fetch
793
+ original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
794
+ end
573
795
 
574
796
  if original_klass.nil?
575
797
  original_klass = klass
@@ -583,8 +805,11 @@ values ?s {<#{self.graph_id}>}
583
805
  end
584
806
  end
585
807
 
808
+ # Cache entity for potential reuse in recursive calls
809
+ known_entities[uuid] = original_klass
810
+
586
811
  begin
587
- make_graph(graph, hierarchy, id, original_klass, klass_metadata, resolve_all)
812
+ make_graph(graph, hierarchy, id, original_klass, klass_metadata, resolve_all, known_entities, skip_store_fetch: skip_store_fetch)
588
813
  rescue => e
589
814
  Solis::LOGGER.error(e.message)
590
815
  raise e
@@ -594,14 +819,19 @@ values ?s {<#{self.graph_id}>}
594
819
  id
595
820
  end
596
821
 
597
- def make_graph(graph, hierarchy, id, klass, klass_metadata, resolve_all)
822
+ def make_graph(graph, hierarchy, id, klass, klass_metadata, resolve_all, known_entities = {}, skip_store_fetch: false)
598
823
  klass_metadata[:attributes].each do |attribute, metadata|
599
824
  data = klass.instance_variable_get("@#{attribute}")
600
825
 
601
826
  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
602
827
  if data.nil?
603
828
  uuid = id.value.split('/').last
604
- original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
829
+ # Use cached entity if available (skip store fetch for new entities)
830
+ original_klass = known_entities[uuid]
831
+ if original_klass.nil? && !skip_store_fetch
832
+ original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
833
+ known_entities[uuid] = original_klass if original_klass
834
+ end
605
835
  unless original_klass.nil?
606
836
  klass = original_klass
607
837
  data = klass.instance_variable_get("@#{attribute}")
@@ -642,10 +872,10 @@ values ?s {<#{self.graph_id}>}
642
872
  if self.class.graph.shape_as_model(d.class.name).metadata[:attributes].select { |_, v| v[:node_kind].is_a?(RDF::URI) }.size > 0 &&
643
873
  hierarchy.select { |s| s =~ /^#{d.class.name}/ }.size == 0
644
874
  internal_resolve = false
645
- d = build_ttl_objekt(graph, d, hierarchy, internal_resolve)
875
+ d = build_ttl_objekt(graph, d, hierarchy, internal_resolve, known_entities)
646
876
  elsif self.class.graph.shape_as_model(d.class.name) && hierarchy.select { |s| s =~ /^#{d.class.name}/ }.size == 0
647
877
  internal_resolve = false
648
- d = build_ttl_objekt(graph, d, hierarchy, internal_resolve)
878
+ d = build_ttl_objekt(graph, d, hierarchy, internal_resolve, known_entities)
649
879
  else
650
880
  d = "#{klass.class.graph_name}#{d.class.name.tableize}/#{d.id}"
651
881
  end
@@ -714,96 +944,6 @@ values ?s {<#{self.graph_id}>}
714
944
  raise e
715
945
  end
716
946
 
717
- def build_ttl_objekt_old(graph, klass, hierarchy = [], resolve_all = true)
718
- hierarchy.push("#{klass.class.name}(#{klass.instance_variables.include?(:@id) ? klass.instance_variable_get("@id") : ''})")
719
- sparql_endpoint = self.class.sparql_endpoint
720
- if klass.instance_variables.include?(:@id) && hierarchy.length > 1
721
- unless sparql_endpoint.nil?
722
- existing_klass = klass.query.filter({ filters: { id: [klass.instance_variable_get("@id")] } }).find_all { |f| f.id == klass.instance_variable_get("@id") }
723
- if !existing_klass.nil? && !existing_klass.empty? && existing_klass.first.is_a?(klass.class)
724
- klass = existing_klass.first
725
- end
726
- end
727
- end
728
-
729
- uuid = klass.instance_variable_get("@id") || SecureRandom.uuid
730
- id = RDF::URI("#{self.class.graph_name}#{klass.class.name.tableize}/#{uuid}")
731
- graph << [id, RDF::RDFV.type, klass.class.metadata[:target_class]]
732
-
733
- klass.class.metadata[:attributes].each do |attribute, metadata|
734
- data = klass.instance_variable_get("@#{attribute}")
735
- if data.nil? && metadata[:datatype_rdf].eql?('http://www.w3.org/2001/XMLSchema#boolean')
736
- data = false
737
- end
738
-
739
- if metadata[:datatype_rdf].eql?("http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON")
740
- data = data.to_json
741
- end
742
-
743
- if data.nil? && metadata[:mincount] > 0
744
- raise Solis::Error::InvalidAttributeError, "#{hierarchy.join('.')}.#{attribute} min=#{metadata[:mincount]} and max=#{metadata[:maxcount]}"
745
- end
746
-
747
- next if data.nil? || ([Hash, Array, String].include?(data.class) && data&.empty?)
748
-
749
- data = [data] unless data.is_a?(Array)
750
- model = nil
751
- model = klass.class.graph.shape_as_model(klass.class.metadata[:attributes][attribute][:datatype].to_s) unless klass.class.metadata[:attributes][attribute][:node_kind].nil?
752
-
753
- data.each do |d|
754
- original_d = d
755
- if model
756
- target_node = model.metadata[:target_node].value.split('/').last.gsub(/Shape$/, '')
757
- if model.ancestors[0..model.ancestors.find_index(Solis::Model) - 1].map { |m| m.name }.include?(target_node)
758
- parent_model = model.graph.shape_as_model(target_node)
759
- end
760
- end
761
-
762
- if model && d.is_a?(Hash)
763
- model_instance = model.descendants.map { |m| m&.new(d) rescue nil }.compact.first || nil
764
- model_instance = model.new(d) if model_instance.nil?
765
-
766
- if resolve_all
767
- d = build_ttl_objekt(graph, model_instance, hierarchy, false)
768
- else
769
- real_model = model_instance.query.filter({ filters: { id: model_instance.id } }).find_all { |f| f.id == model_instance.id }&.first
770
- d = RDF::URI("#{self.class.graph_name}#{real_model ? real_model.class.name.tableize : model_instance.class.name.tableize}/#{model_instance.id}")
771
- end
772
- elsif model && d.is_a?(model)
773
- if resolve_all
774
- if parent_model
775
- model_instance = parent_model.new({ id: d.id })
776
- d = build_ttl_objekt(graph, model_instance, hierarchy, false)
777
- else
778
- d = build_ttl_objekt(graph, d, hierarchy, false)
779
- end
780
- else
781
- real_model = model.new.query.filter({ filters: { id: d.id } }).find_all { |f| f.id == d.id }&.first
782
- d = RDF::URI("#{self.class.graph_name}#{real_model ? real_model.class.name.tableize : model.name.tableize}/#{d.id}")
783
- end
784
- else
785
- datatype = RDF::Vocabulary.find_term(metadata[:datatype_rdf] || metadata[:node])
786
- if datatype && datatype.datatype?
787
- d = if metadata[:datatype_rdf].eql?('http://www.w3.org/1999/02/22-rdf-syntax-ns#langString')
788
- RDF::Literal.new(d, language: self.class.language)
789
- else
790
- if metadata[:datatype_rdf].eql?('http://www.w3.org/2001/XMLSchema#anyURI')
791
- RDF::Literal.new(d.to_s, datatype: RDF::XSD.anyURI)
792
- else
793
- RDF::Literal.new(d, datatype: datatype)
794
- end
795
- end
796
- d = (d.object.value rescue d.object) unless d.valid?
797
- end
798
- end
799
-
800
- graph << [id, RDF::URI("#{self.class.graph_name}#{attribute}"), d]
801
- end
802
- end
803
- hierarchy.pop
804
- id
805
- end
806
-
807
947
  def properties_to_hash(model)
808
948
  n = {}
809
949
  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)
@@ -27,12 +27,18 @@ module Solis
27
27
  end
28
28
 
29
29
  def up?
30
- result = nil
30
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+ if @up_checked_at && (now - @up_checked_at) < 30
32
+ return @up_result
33
+ end
34
+ @up_result = nil
31
35
  @pool.with do |c|
32
- result = c.query("ASK WHERE { ?s ?p ?o }")
36
+ @up_result = c.query("ASK WHERE { ?s ?p ?o }")
33
37
  end
34
- result
38
+ @up_checked_at = now
39
+ @up_result
35
40
  rescue HTTP::Error => e
41
+ @up_checked_at = nil
36
42
  return false
37
43
  end
38
44
 
data/lib/solis/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Solis
2
- VERSION = "0.114.0"
2
+ VERSION = "0.116.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.114.0
4
+ version: 0.116.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehmet Celik