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 +4 -4
- data/README.md +83 -1
- data/lib/solis/graph.rb +4 -0
- data/lib/solis/model.rb +337 -197
- data/lib/solis/query.rb +38 -1
- data/lib/solis/store/sparql/client.rb +9 -3
- data/lib/solis/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: affbb3891268851909a6a35684c11a36f6e67d617dea1e977015124ca0bd8118
|
|
4
|
+
data.tar.gz: 920ad82dc52d0bdefe3bd1e08b631e6907d6f13952f5573fc3bb8f392b03521d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
#
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 =
|
|
365
|
+
data = original_klass
|
|
282
366
|
else
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
536
|
-
if
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
572
|
-
original_klass =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
@up_result = c.query("ASK WHERE { ?s ?p ?o }")
|
|
33
37
|
end
|
|
34
|
-
|
|
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