solis 0.113.0 → 0.115.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: 250f917e5faee6c5f5babc190c2f225defe07b9a32f53908e929b81714b61908
4
- data.tar.gz: ed611b0714eada966c6b9e82e1b44aabfbd45563ceead625ad249d5896f7583e
3
+ metadata.gz: 7c9039f8bba386333b11237b9914c7fdeee57ad8094ea57058815f2fc008d4a7
4
+ data.tar.gz: ea7269d0380b39c1a88a7d064c503c972194a548441c6c32459ab4ca7187cab8
5
5
  SHA512:
6
- metadata.gz: 29050d4e0148efb7cc7992d1fbd75902290a6f35c16f528eb35aafe2a3d65cb02657c148c02daefe3123145b22fad24238f8e377fed0e9b5317567e8aabd65d7
7
- data.tar.gz: d18917f52c79246927e8235f0ae1eacaf769b361c661294319ad03e09848ad4459dfc0159115a0fc403ace2c7c8efcc79aa48b84ae2a3b26471dd9efa7262c70
6
+ metadata.gz: 17d2aa3f39b2bee0cb267996467ba8a0fe08d433e3e63c9dbab3df8624cac6ebf45703bc90c606f3c7d90d4c0ab12b72c3794056c11481f008141c456fe27675
7
+ data.tar.gz: 0e9faa327c21a51aa3413ed0d90c20f781186c360ae4e33c3d2b6432c1e9779e19c100008197b4fbed8969eefecdf2d9d5776cf18ab72872f0dff95ac334160b
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/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
@@ -22,7 +23,16 @@ module Solis
22
23
  end
23
24
 
24
25
  if self.class.metadata[:attributes][attribute.to_s][:node_kind].is_a?(RDF::URI) && value.is_a?(Hash)
25
- inner_model = self.class.graph.shape_as_model(self.class.metadata[:attributes][attribute.to_s][:datatype].to_s)
26
+ inner_class = self.class.metadata[:attributes][attribute.to_s][:datatype].to_s
27
+ inner_model = self.class.graph.shape_as_model(inner_class)
28
+
29
+ if value.key?('id') && value['id'].match?(self.class.graph_name)
30
+ inner_class = value['id'].gsub(self.class.graph_name, '').split('/').first.classify.to_s
31
+ if inner_model.descendants.map(&:to_s).include?(inner_class)
32
+ inner_model = self.class.graph.shape_as_model(inner_class)
33
+ end
34
+ end
35
+
26
36
  value = inner_model.new(value)
27
37
  elsif self.class.metadata[:attributes][attribute.to_s][:node_kind].is_a?(RDF::URI) && value.is_a?(Array)
28
38
  new_value = []
@@ -141,33 +151,40 @@ values ?s {<#{self.graph_id}>}
141
151
 
142
152
  if self.exists?(sparql)
143
153
  data = properties_to_hash(self)
144
- result = update(data)
154
+ result = update(data, validate_dependencies, top_level, sparql)
145
155
  else
146
156
  data = properties_to_hash(self)
147
157
  attributes = data.include?('attributes') ? data['attributes'] : data
148
158
  readonly_list = (Solis::Options.instance.get[:embedded_readonly] || []).map(&:to_s)
149
159
 
160
+ # Collect all embedded entities first for batched existence check
161
+ all_embedded = []
150
162
  attributes.each_pair do |key, value|
151
163
  unless self.class.metadata[:attributes][key][:node].nil?
152
164
  value = [value] unless value.is_a?(Array)
153
165
  value.each do |sub_value|
154
166
  embedded = self.class.graph.shape_as_model(self.class.metadata[:attributes][key][:datatype].to_s).new(sub_value)
167
+ all_embedded << embedded
168
+ end
169
+ end
170
+ end
155
171
 
156
- if readonly_entity?(embedded, readonly_list)
157
- # Readonly entities (code tables) should never be modified
158
- # Only verify they exist, do not create or update them
159
- unless embedded.exists?(sparql)
160
- Solis::LOGGER.warn("#{embedded.class.name} (id: #{embedded.id}) is readonly but does not exist in database. Skipping.")
161
- end
162
- else
163
- # Non-readonly entities can be created or updated
164
- if embedded.exists?(sparql)
165
- embedded_data = properties_to_hash(embedded)
166
- embedded.update(embedded_data, validate_dependencies, false)
167
- else
168
- embedded.save(validate_dependencies, false)
169
- end
170
- end
172
+ # Batch check existence of all embedded entities in one query
173
+ existing_ids = self.class.batch_exists?(sparql, all_embedded)
174
+
175
+ all_embedded.each do |embedded|
176
+ entity_exists = existing_ids.include?(embedded.graph_id)
177
+
178
+ if readonly_entity?(embedded, readonly_list)
179
+ unless entity_exists
180
+ Solis::LOGGER.warn("#{embedded.class.name} (id: #{embedded.id}) is readonly but does not exist in database. Skipping.")
181
+ end
182
+ 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)
171
188
  end
172
189
  end
173
190
  end
@@ -186,14 +203,28 @@ values ?s {<#{self.graph_id}>}
186
203
  raise e
187
204
  end
188
205
 
189
- def update(data, validate_dependencies = true, top_level = true)
206
+ # Update an entity.
207
+ #
208
+ # @param data [Hash] the attributes to update. Must include 'id'.
209
+ # @param validate_dependencies [Boolean] whether to validate dependencies (default: true)
210
+ # @param top_level [Boolean] whether this is a top-level call (default: true)
211
+ # @param sparql_client [SPARQL::Client, nil] optional reusable SPARQL client
212
+ # @param patch [Boolean] when true, uses PATCH semantics:
213
+ # - Only provided attributes are changed; omitted attributes are untouched
214
+ # - Embedded entity arrays are merged (new IDs added, existing IDs updated,
215
+ # missing IDs are kept as-is — not orphaned)
216
+ # - No orphan detection or deletion
217
+ # When false (default), uses PUT semantics:
218
+ # - Embedded entity arrays are fully replaced
219
+ # - 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)
190
221
  raise Solis::Error::GeneralError, "I need a SPARQL endpoint" if self.class.sparql_endpoint.nil?
191
222
 
192
223
  attributes = data.include?('attributes') ? data['attributes'] : data
193
224
  raise "id is mandatory when updating" unless attributes.keys.include?('id')
194
225
 
195
226
  id = attributes.delete('id')
196
- sparql = SPARQL::Client.new(self.class.sparql_endpoint)
227
+ sparql = sparql_client || SPARQL::Client.new(self.class.sparql_endpoint)
197
228
 
198
229
  original_klass = self.query.filter({ language: self.class.language, filters: { id: [id] } }).find_all.map { |m| m }&.first
199
230
  raise Solis::Error::NotFoundError if original_klass.nil?
@@ -202,63 +233,89 @@ values ?s {<#{self.graph_id}>}
202
233
  # Cache readonly entities list once
203
234
  readonly_list = (Solis::Options.instance.get[:embedded_readonly] || []).map(&:to_s)
204
235
 
205
- # Track entities to potentially delete
236
+ # Track entities to potentially delete (only used in PUT mode)
206
237
  entities_to_check_for_deletion = {}
207
238
 
239
+ # First pass: collect all embedded entities for batched existence check
240
+ embedded_by_key = {}
208
241
  attributes.each_pair do |key, value|
209
242
  unless original_klass.class.metadata[:attributes][key][:node].nil?
210
243
  value = [value] unless value.is_a?(Array)
244
+ embedded_by_key[key] = value.map do |sub_value|
245
+ self.class.graph.shape_as_model(original_klass.class.metadata[:attributes][key][:datatype].to_s).new(sub_value)
246
+ end
247
+ end
248
+ end
211
249
 
212
- # Get original embedded entities for this attribute
213
- original_embedded = original_klass.instance_variable_get("@#{key}")
214
- original_embedded = [original_embedded] unless original_embedded.nil? || original_embedded.is_a?(Array)
215
- original_embedded ||= []
250
+ all_embedded = embedded_by_key.values.flatten
251
+ existing_ids = self.class.batch_exists?(sparql, all_embedded)
216
252
 
217
- # Track original IDs
218
- original_ids = original_embedded.map { |e| solis_model?(e) ? e.id : nil }.compact
253
+ # Second pass: process embedded entities using batched results
254
+ embedded_by_key.each do |key, embedded_list|
255
+ value = attributes[key]
256
+ value = [value] unless value.is_a?(Array)
219
257
 
220
- # Build new array of embedded entities
221
- new_embedded_values = []
222
- new_ids = []
258
+ # Get original embedded entities for this attribute
259
+ original_embedded = original_klass.instance_variable_get("@#{key}")
260
+ original_embedded = [original_embedded] unless original_embedded.nil? || original_embedded.is_a?(Array)
261
+ original_embedded ||= []
223
262
 
224
- value.each do |sub_value|
225
- embedded = self.class.graph.shape_as_model(original_klass.class.metadata[:attributes][key][:datatype].to_s).new(sub_value)
226
- new_ids << embedded.id if embedded.id
263
+ # Track original IDs
264
+ original_ids = original_embedded.map { |e| solis_model?(e) ? e.id : nil }.compact
227
265
 
228
- if readonly_entity?(embedded, readonly_list)
229
- # Readonly entities (code tables) should never be modified
230
- # Only verify they exist, do not create or update them
231
- if embedded.exists?(sparql)
232
- new_embedded_values << embedded
233
- else
234
- Solis::LOGGER.warn("#{embedded.class.name} (id: #{embedded.id}) is readonly but does not exist in database. Skipping.")
235
- end
266
+ # Build new array of embedded entities
267
+ new_embedded_values = []
268
+ new_ids = []
269
+
270
+ embedded_list.each do |embedded|
271
+ new_ids << embedded.id if embedded.id
272
+ entity_exists = existing_ids.include?(embedded.graph_id)
273
+
274
+ if readonly_entity?(embedded, readonly_list)
275
+ if entity_exists
276
+ new_embedded_values << embedded
236
277
  else
237
- # Non-readonly entities can be created or updated
238
- if embedded.exists?(sparql)
239
- embedded_data = properties_to_hash(embedded)
240
- embedded.update(embedded_data, validate_dependencies, false)
241
- new_embedded_values << embedded
242
- else
243
- embedded_value = embedded.save(validate_dependencies, false)
244
- new_embedded_values << embedded_value
245
- end
278
+ Solis::LOGGER.warn("#{embedded.class.name} (id: #{embedded.id}) is readonly but does not exist in database. Skipping.")
279
+ end
280
+ else
281
+ if entity_exists
282
+ embedded_data = properties_to_hash(embedded)
283
+ embedded.update(embedded_data, validate_dependencies, false)
284
+ new_embedded_values << embedded
285
+ else
286
+ embedded_value = embedded.save(validate_dependencies, false)
287
+ new_embedded_values << embedded_value
246
288
  end
247
289
  end
290
+ end
291
+
292
+ if patch
293
+ # PATCH mode: merge new embedded entities into the original array.
294
+ # Keep original entities that were not mentioned in the update data.
295
+ unmentioned = original_embedded.select do |e|
296
+ solis_model?(e) && !new_ids.include?(e.id)
297
+ end
298
+ merged_values = unmentioned + new_embedded_values
299
+ else
300
+ # PUT mode: replace the entire array; detect orphans for deletion
301
+ merged_values = new_embedded_values
248
302
 
249
- # Identify orphaned entities (in original but not in new)
250
- # Note: Readonly entities will be filtered out in delete_orphaned_entities
251
303
  orphaned_ids = original_ids - new_ids
252
304
  unless orphaned_ids.empty?
253
305
  orphaned_entities = original_embedded.select { |e| solis_model?(e) && orphaned_ids.include?(e.id) }
254
306
  entities_to_check_for_deletion[key] = orphaned_entities
255
307
  end
256
-
257
- # Replace entire array with new values
258
- maxcount = original_klass.class.metadata[:attributes][key][:maxcount]
259
- value = maxcount && maxcount == 1 ? new_embedded_values.first : new_embedded_values
260
308
  end
261
309
 
310
+ maxcount = original_klass.class.metadata[:attributes][key][:maxcount]
311
+ embedded_value = maxcount && maxcount == 1 ? merged_values.first : merged_values
312
+ updated_klass.instance_variable_set("@#{key}", embedded_value)
313
+ end
314
+
315
+ # Process non-embedded attributes
316
+ attributes.each_pair do |key, value|
317
+ next unless original_klass.class.metadata[:attributes][key][:node].nil?
318
+
262
319
  updated_klass.instance_variable_set("@#{key}", value)
263
320
  end
264
321
 
@@ -269,9 +326,11 @@ values ?s {<#{self.graph_id}>}
269
326
 
270
327
  if Hashdiff.best_diff(properties_original_klass, properties_updated_klass).empty?
271
328
  Solis::LOGGER.info("#{original_klass.class.name} unchanged, skipping")
272
- data = self.query.filter({ filters: { id: [id] } }).find_all.map { |m| m }&.first
329
+ data = original_klass
273
330
  else
274
- delete_graph = as_graph(original_klass, false)
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)
275
334
  where_graph = RDF::Graph.new(graph_name: RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), data: RDF::Repository.new)
276
335
 
277
336
  if id.is_a?(Array)
@@ -282,21 +341,22 @@ values ?s {<#{self.graph_id}>}
282
341
  where_graph << [RDF::URI("#{self.class.graph_name}#{tableized_class_name(self)}/#{id}"), :p, :o]
283
342
  end
284
343
 
285
- insert_graph = as_graph(updated_klass, true)
344
+ insert_graph = as_graph(updated_klass, true, known_entities)
286
345
 
287
346
  delete_insert_query = SPARQL::Client::Update::DeleteInsert.new(delete_graph, insert_graph, where_graph, graph: insert_graph.name).to_s
288
347
  delete_insert_query.gsub!('_:p', '?p')
289
348
 
290
- data = sparql.query(delete_insert_query)
349
+ sparql.query(delete_insert_query)
291
350
 
351
+ # Verify the update succeeded by re-fetching; fallback to insert if needed
292
352
  data = self.query.filter({ filters: { id: [id] } }).find_all.map { |m| m }&.first
293
353
  if data.nil?
294
354
  sparql.insert_data(insert_graph, graph: insert_graph.name)
295
- data = self.query.filter({ filters: { id: [id] } }).find_all.map { |m| m }&.first
355
+ data = updated_klass
296
356
  end
297
357
 
298
- # Delete orphaned entities after successful update
299
- delete_orphaned_entities(entities_to_check_for_deletion, sparql)
358
+ # Delete orphaned entities after successful update (PUT mode only)
359
+ delete_orphaned_entities(entities_to_check_for_deletion, sparql) unless patch
300
360
  end
301
361
 
302
362
  after_update_proc&.call(updated_klass, data)
@@ -324,18 +384,24 @@ values ?s {<#{self.graph_id}>}
324
384
  sparql.query("ASK WHERE { <#{self.graph_id}> ?p ?o }")
325
385
  end
326
386
 
387
+ # Check existence of multiple entities in a single SPARQL query
388
+ # Returns a Set of graph_ids that exist
389
+ def self.batch_exists?(sparql, entities)
390
+ return Set.new if entities.empty?
391
+ return Set.new([entities.first.graph_id]) if entities.size == 1 && entities.first.exists?(sparql)
392
+ return Set.new if entities.size == 1
393
+
394
+ values = entities.map { |e| "<#{e.graph_id}>" }.join(' ')
395
+ query = "SELECT DISTINCT ?s WHERE { VALUES ?s { #{values} } . ?s ?p ?o }"
396
+ results = sparql.query(query)
397
+ Set.new(results.map { |r| r[:s].to_s })
398
+ end
399
+
327
400
  def self.make_id_for(model)
328
- raise "I need a SPARQL endpoint" if self.sparql_endpoint.nil?
329
- sparql = Solis::Store::Sparql::Client.new(self.sparql_endpoint)
330
401
  id = model.instance_variable_get("@id")
331
402
  if id.nil? || (id.is_a?(String) && id&.empty?)
332
- id_retries = 0
333
-
334
- while id.nil? || sparql.query("ASK WHERE { ?s <#{self.graph_name}id> \"#{id}\" }")
335
- id = SecureRandom.uuid
336
- id_retries += 1
337
- end
338
- LOGGER.info("ID(#{id}) generated for #{self.name} in #{id_retries} retries") if ConfigFile[:debug]
403
+ id = SecureRandom.uuid
404
+ LOGGER.info("ID(#{id}) generated for #{self.name}") if ConfigFile[:debug]
339
405
  model.instance_variable_set("@id", id)
340
406
  elsif id.to_s =~ /^https?:\/\//
341
407
  id = id.to_s.split('/').last
@@ -488,6 +554,13 @@ values ?s {<#{self.graph_id}>}
488
554
  (entity.class.ancestors.map(&:to_s) & readonly_list).any?
489
555
  end
490
556
 
557
+ # Helper method to check if an entity is a top-level entity (has its own shape definition).
558
+ # Top-level entities are independently addressable and should not be cascade-deleted
559
+ # when unlinked from a parent, unless explicitly opted in via embedded_delete config.
560
+ def top_level_entity?(entity)
561
+ self.class.graph.shape?(entity.class.name)
562
+ end
563
+
491
564
  # Helper method to get tableized class name
492
565
  def tableized_class_name(obj)
493
566
  obj.class.name.tableize
@@ -505,50 +578,85 @@ values ?s {<#{self.graph_id}>}
505
578
  RDF::URI("#{self.class.graph_name}#{class_name.tableize}/#{id}")
506
579
  end
507
580
 
508
- # Delete orphaned entities that are no longer referenced
581
+ # Delete orphaned entities that are no longer referenced.
582
+ #
583
+ # Decision logic (in order of precedence):
584
+ # 1. embedded_readonly → never delete (code tables)
585
+ # 2. Top-level entity (has own shape) + NOT in embedded_delete → unlink only, don't delete
586
+ # 3. Top-level entity + listed in embedded_delete → delete (opt-in override)
587
+ # 4. Still referenced by other entities (batch_referenced?) → never delete (safety net)
509
588
  def delete_orphaned_entities(entities_to_check, sparql)
510
589
  return if entities_to_check.nil? || entities_to_check.empty?
511
590
 
512
591
  readonly_list = (Solis::Options.instance.get[:embedded_readonly] || []).map(&:to_s)
592
+ delete_list = (Solis::Options.instance.get[:embedded_delete] || []).map(&:to_s)
513
593
 
514
- entities_to_check.each do |key, orphaned_entities|
594
+ # Collect all deletable orphans
595
+ deletable_orphans = []
596
+ entities_to_check.each do |_key, orphaned_entities|
515
597
  next if orphaned_entities.nil?
516
-
517
598
  orphaned_entities.each do |orphaned_entity|
518
599
  next unless solis_model?(orphaned_entity)
519
600
 
520
- # Skip if it's a readonly entity (like code tables)
601
+ # 1. Never delete readonly entities (code tables)
521
602
  if readonly_entity?(orphaned_entity, readonly_list)
522
603
  Solis::LOGGER.info("#{orphaned_entity.class.name} (id: #{orphaned_entity.id}) is in embedded_readonly list. Skipping deletion.")
523
604
  next
524
605
  end
525
606
 
526
- # Check if the entity is still referenced elsewhere
527
- if orphaned_entity.is_referenced?(sparql)
528
- Solis::LOGGER.info("#{orphaned_entity.class.name} (id: #{orphaned_entity.id}) is still referenced elsewhere. Skipping deletion.")
529
- next
607
+ # 2. Top-level entities are never auto-deleted unless opted in via embedded_delete
608
+ if top_level_entity?(orphaned_entity)
609
+ explicitly_deletable = (orphaned_entity.class.ancestors.map(&:to_s) & delete_list).any?
610
+ unless explicitly_deletable
611
+ Solis::LOGGER.info("#{orphaned_entity.class.name} (id: #{orphaned_entity.id}) is a top-level entity. Skipping deletion (unlink only).")
612
+ next
613
+ end
530
614
  end
531
615
 
532
- # Safe to delete the orphan
533
- begin
534
- Solis::LOGGER.info("Deleting orphaned entity: #{orphaned_entity.class.name} (id: #{orphaned_entity.id})")
535
- orphaned_entity.destroy
536
- rescue StandardError => e
537
- Solis::LOGGER.error("Failed to delete orphaned entity #{orphaned_entity.class.name} (id: #{orphaned_entity.id}): #{e.message}")
538
- end
616
+ deletable_orphans << orphaned_entity
617
+ end
618
+ end
619
+
620
+ return if deletable_orphans.empty?
621
+
622
+ # 4. Safety net: batch check which orphans are still referenced elsewhere
623
+ referenced_ids = batch_referenced?(sparql, deletable_orphans)
624
+
625
+ deletable_orphans.each do |orphaned_entity|
626
+ if referenced_ids.include?(orphaned_entity.graph_id)
627
+ Solis::LOGGER.info("#{orphaned_entity.class.name} (id: #{orphaned_entity.id}) is still referenced elsewhere. Skipping deletion.")
628
+ next
629
+ end
630
+
631
+ begin
632
+ Solis::LOGGER.info("Deleting orphaned entity: #{orphaned_entity.class.name} (id: #{orphaned_entity.id})")
633
+ orphaned_entity.destroy
634
+ rescue StandardError => e
635
+ Solis::LOGGER.error("Failed to delete orphaned entity #{orphaned_entity.class.name} (id: #{orphaned_entity.id}): #{e.message}")
539
636
  end
540
637
  end
541
638
  end
542
639
 
543
- def as_graph(klass = self, resolve_all = true)
640
+ # Batch check which entities are still referenced by other entities
641
+ # Returns a Set of graph_ids that are referenced
642
+ def batch_referenced?(sparql, entities)
643
+ return Set.new if entities.empty?
644
+
645
+ values = entities.map { |e| "<#{e.graph_id}>" }.join(' ')
646
+ query = "SELECT DISTINCT ?o WHERE { VALUES ?o { #{values} } . ?s ?p ?o . FILTER(!CONTAINS(STR(?s), 'audit') && !CONTAINS(STR(?p), 'audit')) }"
647
+ results = sparql.query(query)
648
+ Set.new(results.map { |r| r[:o].to_s })
649
+ end
650
+
651
+ def as_graph(klass = self, resolve_all = true, known_entities = {})
544
652
  graph = RDF::Graph.new
545
653
  graph.name = RDF::URI(self.class.graph_name)
546
- id = build_ttl_objekt(graph, klass, [], resolve_all)
654
+ id = build_ttl_objekt(graph, klass, [], resolve_all, known_entities)
547
655
 
548
656
  graph
549
657
  end
550
658
 
551
- def build_ttl_objekt(graph, klass, hierarchy = [], resolve_all = true)
659
+ def build_ttl_objekt(graph, klass, hierarchy = [], resolve_all = true, known_entities = {})
552
660
  hierarchy.push("#{klass.class.name}(#{klass.instance_variables.include?(:@id) ? klass.instance_variable_get("@id") : ''})")
553
661
 
554
662
  graph_name = self.class.graph_name
@@ -559,8 +667,11 @@ values ?s {<#{self.graph_id}>}
559
667
 
560
668
  graph << [id, RDF::RDFV.type, klass_metadata[:target_class]]
561
669
 
562
- # load existing object and overwrite
563
- original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
670
+ # Use cached entity if available, otherwise query the store
671
+ original_klass = known_entities[uuid]
672
+ if original_klass.nil?
673
+ original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
674
+ end
564
675
 
565
676
  if original_klass.nil?
566
677
  original_klass = klass
@@ -574,8 +685,11 @@ values ?s {<#{self.graph_id}>}
574
685
  end
575
686
  end
576
687
 
688
+ # Cache entity for potential reuse in recursive calls
689
+ known_entities[uuid] = original_klass
690
+
577
691
  begin
578
- make_graph(graph, hierarchy, id, original_klass, klass_metadata, resolve_all)
692
+ make_graph(graph, hierarchy, id, original_klass, klass_metadata, resolve_all, known_entities)
579
693
  rescue => e
580
694
  Solis::LOGGER.error(e.message)
581
695
  raise e
@@ -585,14 +699,19 @@ values ?s {<#{self.graph_id}>}
585
699
  id
586
700
  end
587
701
 
588
- def make_graph(graph, hierarchy, id, klass, klass_metadata, resolve_all)
702
+ def make_graph(graph, hierarchy, id, klass, klass_metadata, resolve_all, known_entities = {})
589
703
  klass_metadata[:attributes].each do |attribute, metadata|
590
704
  data = klass.instance_variable_get("@#{attribute}")
591
705
 
592
706
  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
593
707
  if data.nil?
594
708
  uuid = id.value.split('/').last
595
- original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
709
+ # Use cached entity if available
710
+ original_klass = known_entities[uuid]
711
+ if original_klass.nil?
712
+ original_klass = klass.query.filter({ filters: { id: [uuid] } }).find_all { |f| f.id == uuid }.first || nil
713
+ known_entities[uuid] = original_klass if original_klass
714
+ end
596
715
  unless original_klass.nil?
597
716
  klass = original_klass
598
717
  data = klass.instance_variable_get("@#{attribute}")
@@ -633,10 +752,10 @@ values ?s {<#{self.graph_id}>}
633
752
  if self.class.graph.shape_as_model(d.class.name).metadata[:attributes].select { |_, v| v[:node_kind].is_a?(RDF::URI) }.size > 0 &&
634
753
  hierarchy.select { |s| s =~ /^#{d.class.name}/ }.size == 0
635
754
  internal_resolve = false
636
- d = build_ttl_objekt(graph, d, hierarchy, internal_resolve)
755
+ d = build_ttl_objekt(graph, d, hierarchy, internal_resolve, known_entities)
637
756
  elsif self.class.graph.shape_as_model(d.class.name) && hierarchy.select { |s| s =~ /^#{d.class.name}/ }.size == 0
638
757
  internal_resolve = false
639
- d = build_ttl_objekt(graph, d, hierarchy, internal_resolve)
758
+ d = build_ttl_objekt(graph, d, hierarchy, internal_resolve, known_entities)
640
759
  else
641
760
  d = "#{klass.class.graph_name}#{d.class.name.tableize}/#{d.id}"
642
761
  end
@@ -755,21 +874,21 @@ values ?s {<#{self.graph_id}>}
755
874
  model_instance = model.new(d) if model_instance.nil?
756
875
 
757
876
  if resolve_all
758
- d = build_ttl_objekt(graph, model_instance, hierarchy, false)
877
+ d = build_ttl_objekt(graph, model_instance, hierarchy, false, known_entities)
759
878
  else
760
- real_model = model_instance.query.filter({ filters: { id: model_instance.id } }).find_all { |f| f.id == model_instance.id }&.first
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
761
880
  d = RDF::URI("#{self.class.graph_name}#{real_model ? real_model.class.name.tableize : model_instance.class.name.tableize}/#{model_instance.id}")
762
881
  end
763
882
  elsif model && d.is_a?(model)
764
883
  if resolve_all
765
884
  if parent_model
766
885
  model_instance = parent_model.new({ id: d.id })
767
- d = build_ttl_objekt(graph, model_instance, hierarchy, false)
886
+ d = build_ttl_objekt(graph, model_instance, hierarchy, false, known_entities)
768
887
  else
769
- d = build_ttl_objekt(graph, d, hierarchy, false)
888
+ d = build_ttl_objekt(graph, d, hierarchy, false, known_entities)
770
889
  end
771
890
  else
772
- real_model = model.new.query.filter({ filters: { id: d.id } }).find_all { |f| f.id == d.id }&.first
891
+ real_model = known_entities[d.id] || model.new.query.filter({ filters: { id: d.id } }).find_all { |f| f.id == d.id }&.first
773
892
  d = RDF::URI("#{self.class.graph_name}#{real_model ? real_model.class.name.tableize : model.name.tableize}/#{d.id}")
774
893
  end
775
894
  else
@@ -89,6 +89,9 @@ module Solis
89
89
  v=normalize_string(v)
90
90
  filter = "filter( !exists {?concept <#{@model.class.graph_name}id> \"#{v}\"})"
91
91
  end
92
+ elsif ['>', '<', '>=', '<='].include?(value[:operator])
93
+ v = normalize_string(value[:value].first)
94
+ filter = "?concept <#{@model.class.graph_name}id> ?__search FILTER (?__search #{value[:operator]} \"#{v}\") .\n"
92
95
  else
93
96
  filter = "?concept <#{@model.class.graph_name}id> ?__search FILTER (?__search IN(#{contains})) .\n"
94
97
  end
@@ -44,10 +44,10 @@ module Solis
44
44
  big_decimal: [:eq, :not_eq, :gt, :lt],
45
45
  date: [:eq, :not_eq, :gt, :gte, :lt, :lte],
46
46
  boolean: [:eq, :not_eq],
47
- uuid: [:eq, :not_eq],
47
+ uuid: [:eq, :not_eq, :gt, :lt],
48
48
  enum: [:eq],
49
49
  datetime: [:eq, :not_eq, :gt, :lt],
50
- anyuri: [:eq, :not_eq],
50
+ anyuri: [:eq, :not_eq, :gt, :lt],
51
51
  }
52
52
  end
53
53
 
@@ -106,6 +106,7 @@ module Solis
106
106
  alias :filter_uuid_gt :filter_gt
107
107
  alias :filter_enum_gt :filter_gt
108
108
  alias :filter_datetime_gt :filter_gt
109
+ alias :filter_anyuri_gt :filter_gt
109
110
 
110
111
  def filter_not_gt(scope, attribute, value)
111
112
  filter_eq(scope, attribute, value, true, '>')
@@ -134,6 +135,7 @@ module Solis
134
135
  alias :filter_uuid_lt :filter_lt
135
136
  alias :filter_enum_lt :filter_lt
136
137
  alias :filter_datetime_lt :filter_lt
138
+ alias :filter_anyuri_lt :filter_lt
137
139
 
138
140
  def filter_not_lt(scope, attribute, value)
139
141
  filter_eq(scope, attribute, value, true, '<')
@@ -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.113.0"
2
+ VERSION = "0.115.0"
3
3
  end
data/solis.gemspec CHANGED
@@ -28,7 +28,7 @@ Gem::Specification.new do |spec|
28
28
  spec.require_paths = ['lib']
29
29
 
30
30
  spec.add_runtime_dependency 'activesupport', '~> 8.0'
31
- spec.add_runtime_dependency 'http', '~> 5.2'
31
+ spec.add_runtime_dependency 'http', '~> 5.3'
32
32
  spec.add_runtime_dependency 'graphiti', '~> 1.8'
33
33
  spec.add_runtime_dependency 'moneta', '~> 1.6'
34
34
  spec.add_runtime_dependency 'linkeddata', '~> 3.3'
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.113.0
4
+ version: 0.115.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mehmet Celik
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '5.2'
32
+ version: '5.3'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '5.2'
39
+ version: '5.3'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: graphiti
42
42
  requirement: !ruby/object:Gem::Requirement