javascript-solid-server 0.0.9 → 0.0.10
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.
- package/README.md +95 -6
- package/benchmark.js +145 -249
- package/package.json +15 -3
- package/src/handlers/resource.js +100 -33
- package/src/patch/sparql-update.js +401 -0
- package/src/utils/conditional.js +153 -0
- package/test/conditional.test.js +250 -0
- package/test/sparql-update.test.js +219 -0
package/src/handlers/resource.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getAllHeaders } from '../ldp/headers.js';
|
|
|
3
3
|
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
|
|
4
4
|
import { isContainer, getContentType, isRdfContentType } from '../utils/url.js';
|
|
5
5
|
import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
|
|
6
|
+
import { parseSparqlUpdate, applySparqlUpdate } from '../patch/sparql-update.js';
|
|
6
7
|
import {
|
|
7
8
|
selectContentType,
|
|
8
9
|
canAcceptInput,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
RDF_TYPES
|
|
13
14
|
} from '../rdf/conneg.js';
|
|
14
15
|
import { emitChange } from '../notifications/events.js';
|
|
16
|
+
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Handle GET request
|
|
@@ -24,6 +26,15 @@ export async function handleGet(request, reply) {
|
|
|
24
26
|
return reply.code(404).send({ error: 'Not Found' });
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
// Check If-None-Match for conditional GET (304 Not Modified)
|
|
30
|
+
const ifNoneMatch = request.headers['if-none-match'];
|
|
31
|
+
if (ifNoneMatch) {
|
|
32
|
+
const check = checkIfNoneMatchForGet(ifNoneMatch, stats.etag);
|
|
33
|
+
if (!check.ok && check.notModified) {
|
|
34
|
+
return reply.code(304).send();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
const origin = request.headers.origin;
|
|
28
39
|
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
29
40
|
|
|
@@ -185,8 +196,28 @@ export async function handlePut(request, reply) {
|
|
|
185
196
|
});
|
|
186
197
|
}
|
|
187
198
|
|
|
188
|
-
// Check if resource already exists
|
|
189
|
-
const
|
|
199
|
+
// Check if resource already exists and get current ETag
|
|
200
|
+
const stats = await storage.stat(urlPath);
|
|
201
|
+
const existed = stats !== null;
|
|
202
|
+
const currentEtag = stats?.etag || null;
|
|
203
|
+
|
|
204
|
+
// Check If-Match header (for safe updates)
|
|
205
|
+
const ifMatch = request.headers['if-match'];
|
|
206
|
+
if (ifMatch) {
|
|
207
|
+
const check = checkIfMatch(ifMatch, currentEtag);
|
|
208
|
+
if (!check.ok) {
|
|
209
|
+
return reply.code(check.status).send({ error: check.error });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check If-None-Match header (for create-only semantics)
|
|
214
|
+
const ifNoneMatch = request.headers['if-none-match'];
|
|
215
|
+
if (ifNoneMatch) {
|
|
216
|
+
const check = checkIfNoneMatchForWrite(ifNoneMatch, currentEtag);
|
|
217
|
+
if (!check.ok) {
|
|
218
|
+
return reply.code(check.status).send({ error: check.error });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
190
221
|
|
|
191
222
|
// Get content from request body
|
|
192
223
|
let content = request.body;
|
|
@@ -242,11 +273,21 @@ export async function handlePut(request, reply) {
|
|
|
242
273
|
export async function handleDelete(request, reply) {
|
|
243
274
|
const urlPath = request.url.split('?')[0];
|
|
244
275
|
|
|
245
|
-
|
|
246
|
-
|
|
276
|
+
// Check if resource exists and get current ETag
|
|
277
|
+
const stats = await storage.stat(urlPath);
|
|
278
|
+
if (!stats) {
|
|
247
279
|
return reply.code(404).send({ error: 'Not Found' });
|
|
248
280
|
}
|
|
249
281
|
|
|
282
|
+
// Check If-Match header (for safe deletes)
|
|
283
|
+
const ifMatch = request.headers['if-match'];
|
|
284
|
+
if (ifMatch) {
|
|
285
|
+
const check = checkIfMatch(ifMatch, stats.etag);
|
|
286
|
+
if (!check.ok) {
|
|
287
|
+
return reply.code(check.status).send({ error: check.error });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
250
291
|
const success = await storage.remove(urlPath);
|
|
251
292
|
if (!success) {
|
|
252
293
|
return reply.code(500).send({ error: 'Delete failed' });
|
|
@@ -286,7 +327,7 @@ export async function handleOptions(request, reply) {
|
|
|
286
327
|
|
|
287
328
|
/**
|
|
288
329
|
* Handle PATCH request
|
|
289
|
-
* Supports N3 Patch format (text/n3) for updating RDF resources
|
|
330
|
+
* Supports N3 Patch format (text/n3) and SPARQL Update for updating RDF resources
|
|
290
331
|
*/
|
|
291
332
|
export async function handlePatch(request, reply) {
|
|
292
333
|
const urlPath = request.url.split('?')[0];
|
|
@@ -298,14 +339,13 @@ export async function handlePatch(request, reply) {
|
|
|
298
339
|
|
|
299
340
|
// Check content type
|
|
300
341
|
const contentType = request.headers['content-type'] || '';
|
|
301
|
-
const isN3Patch = contentType.includes('text/n3') ||
|
|
302
|
-
|
|
303
|
-
contentType.includes('application/sparql-update');
|
|
342
|
+
const isN3Patch = contentType.includes('text/n3') || contentType.includes('application/n3');
|
|
343
|
+
const isSparqlUpdate = contentType.includes('application/sparql-update');
|
|
304
344
|
|
|
305
|
-
if (!isN3Patch) {
|
|
345
|
+
if (!isN3Patch && !isSparqlUpdate) {
|
|
306
346
|
return reply.code(415).send({
|
|
307
347
|
error: 'Unsupported Media Type',
|
|
308
|
-
message: 'PATCH requires Content-Type: text/n3
|
|
348
|
+
message: 'PATCH requires Content-Type: text/n3 (N3 Patch) or application/sparql-update (SPARQL Update)'
|
|
309
349
|
});
|
|
310
350
|
}
|
|
311
351
|
|
|
@@ -315,6 +355,15 @@ export async function handlePatch(request, reply) {
|
|
|
315
355
|
return reply.code(404).send({ error: 'Not Found' });
|
|
316
356
|
}
|
|
317
357
|
|
|
358
|
+
// Check If-Match header (for safe updates)
|
|
359
|
+
const ifMatch = request.headers['if-match'];
|
|
360
|
+
if (ifMatch) {
|
|
361
|
+
const check = checkIfMatch(ifMatch, stats.etag);
|
|
362
|
+
if (!check.ok) {
|
|
363
|
+
return reply.code(check.status).send({ error: check.error });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
318
367
|
// Read existing content
|
|
319
368
|
const existingContent = await storage.read(urlPath);
|
|
320
369
|
if (existingContent === null) {
|
|
@@ -338,31 +387,49 @@ export async function handlePatch(request, reply) {
|
|
|
338
387
|
: request.body;
|
|
339
388
|
|
|
340
389
|
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
341
|
-
let patch;
|
|
342
|
-
try {
|
|
343
|
-
patch = parseN3Patch(patchContent, resourceUrl);
|
|
344
|
-
} catch (e) {
|
|
345
|
-
return reply.code(400).send({
|
|
346
|
-
error: 'Bad Request',
|
|
347
|
-
message: 'Invalid N3 Patch format: ' + e.message
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Validate that deletes exist (optional strict mode)
|
|
352
|
-
// const validation = validatePatch(document, patch, resourceUrl);
|
|
353
|
-
// if (!validation.valid) {
|
|
354
|
-
// return reply.code(409).send({ error: 'Conflict', message: validation.error });
|
|
355
|
-
// }
|
|
356
390
|
|
|
357
|
-
// Apply the patch
|
|
358
391
|
let updatedDocument;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
})
|
|
392
|
+
|
|
393
|
+
if (isSparqlUpdate) {
|
|
394
|
+
// Handle SPARQL Update
|
|
395
|
+
let update;
|
|
396
|
+
try {
|
|
397
|
+
update = parseSparqlUpdate(patchContent, resourceUrl);
|
|
398
|
+
} catch (e) {
|
|
399
|
+
return reply.code(400).send({
|
|
400
|
+
error: 'Bad Request',
|
|
401
|
+
message: 'Invalid SPARQL Update: ' + e.message
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
updatedDocument = applySparqlUpdate(document, update, resourceUrl);
|
|
407
|
+
} catch (e) {
|
|
408
|
+
return reply.code(409).send({
|
|
409
|
+
error: 'Conflict',
|
|
410
|
+
message: 'Failed to apply SPARQL Update: ' + e.message
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
// Handle N3 Patch
|
|
415
|
+
let patch;
|
|
416
|
+
try {
|
|
417
|
+
patch = parseN3Patch(patchContent, resourceUrl);
|
|
418
|
+
} catch (e) {
|
|
419
|
+
return reply.code(400).send({
|
|
420
|
+
error: 'Bad Request',
|
|
421
|
+
message: 'Invalid N3 Patch format: ' + e.message
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
updatedDocument = applyN3Patch(document, patch, resourceUrl);
|
|
427
|
+
} catch (e) {
|
|
428
|
+
return reply.code(409).send({
|
|
429
|
+
error: 'Conflict',
|
|
430
|
+
message: 'Failed to apply patch: ' + e.message
|
|
431
|
+
});
|
|
432
|
+
}
|
|
366
433
|
}
|
|
367
434
|
|
|
368
435
|
// Write updated document
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPARQL Update Parser
|
|
3
|
+
*
|
|
4
|
+
* Parses SPARQL Update syntax and applies changes to JSON-LD documents.
|
|
5
|
+
* Supports:
|
|
6
|
+
* - INSERT DATA { triples }
|
|
7
|
+
* - DELETE DATA { triples }
|
|
8
|
+
* - DELETE { pattern } INSERT { pattern } WHERE { pattern }
|
|
9
|
+
*
|
|
10
|
+
* Note: This is a simplified parser for common Solid use cases.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Parser, Writer, DataFactory } from 'n3';
|
|
14
|
+
const { namedNode, literal, blankNode, quad } = DataFactory;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a SPARQL Update query
|
|
18
|
+
* @param {string} sparql - The SPARQL Update query
|
|
19
|
+
* @param {string} baseUri - Base URI for relative references
|
|
20
|
+
* @returns {{ inserts: Array, deletes: Array }}
|
|
21
|
+
*/
|
|
22
|
+
export function parseSparqlUpdate(sparql, baseUri) {
|
|
23
|
+
const result = {
|
|
24
|
+
inserts: [],
|
|
25
|
+
deletes: []
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Normalize whitespace
|
|
29
|
+
const normalized = sparql.trim();
|
|
30
|
+
|
|
31
|
+
// Extract prefixes
|
|
32
|
+
const prefixes = {};
|
|
33
|
+
const prefixRegex = /PREFIX\s+(\w*):?\s*<([^>]+)>/gi;
|
|
34
|
+
let match;
|
|
35
|
+
while ((match = prefixRegex.exec(normalized)) !== null) {
|
|
36
|
+
prefixes[match[1] || ''] = match[2];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Remove prefix declarations for parsing
|
|
40
|
+
let query = normalized.replace(/PREFIX\s+\w*:?\s*<[^>]+>\s*/gi, '').trim();
|
|
41
|
+
|
|
42
|
+
// Handle INSERT DATA
|
|
43
|
+
const insertDataMatch = query.match(/INSERT\s+DATA\s*\{([^}]+)\}/is);
|
|
44
|
+
if (insertDataMatch) {
|
|
45
|
+
const triples = parseTriples(insertDataMatch[1], baseUri, prefixes);
|
|
46
|
+
result.inserts.push(...triples);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Handle DELETE DATA
|
|
50
|
+
const deleteDataMatch = query.match(/DELETE\s+DATA\s*\{([^}]+)\}/is);
|
|
51
|
+
if (deleteDataMatch) {
|
|
52
|
+
const triples = parseTriples(deleteDataMatch[1], baseUri, prefixes);
|
|
53
|
+
result.deletes.push(...triples);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Handle DELETE { } INSERT { } WHERE { }
|
|
57
|
+
// This is a simplified version - we treat DELETE/INSERT as data operations
|
|
58
|
+
const deleteInsertMatch = query.match(/DELETE\s*\{([^}]*)\}\s*INSERT\s*\{([^}]*)\}\s*WHERE\s*\{[^}]*\}/is);
|
|
59
|
+
if (deleteInsertMatch) {
|
|
60
|
+
if (deleteInsertMatch[1].trim()) {
|
|
61
|
+
const deleteTriples = parseTriples(deleteInsertMatch[1], baseUri, prefixes);
|
|
62
|
+
result.deletes.push(...deleteTriples);
|
|
63
|
+
}
|
|
64
|
+
if (deleteInsertMatch[2].trim()) {
|
|
65
|
+
const insertTriples = parseTriples(deleteInsertMatch[2], baseUri, prefixes);
|
|
66
|
+
result.inserts.push(...insertTriples);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Handle DELETE WHERE { } (shorthand for DELETE { pattern } WHERE { pattern })
|
|
71
|
+
const deleteWhereMatch = query.match(/DELETE\s+WHERE\s*\{([^}]+)\}/is);
|
|
72
|
+
if (deleteWhereMatch) {
|
|
73
|
+
const triples = parseTriples(deleteWhereMatch[1], baseUri, prefixes);
|
|
74
|
+
result.deletes.push(...triples);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Handle standalone INSERT { } WHERE { }
|
|
78
|
+
const insertWhereMatch = query.match(/INSERT\s*\{([^}]+)\}\s*WHERE\s*\{[^}]*\}/is);
|
|
79
|
+
if (insertWhereMatch && !deleteInsertMatch) {
|
|
80
|
+
const triples = parseTriples(insertWhereMatch[1], baseUri, prefixes);
|
|
81
|
+
result.inserts.push(...triples);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse Turtle-like triples into an array of triple objects
|
|
89
|
+
* @param {string} triplesStr - Turtle-formatted triples
|
|
90
|
+
* @param {string} baseUri - Base URI
|
|
91
|
+
* @param {object} prefixes - Prefix mappings
|
|
92
|
+
* @returns {Array<{subject: string, predicate: string, object: any}>}
|
|
93
|
+
*/
|
|
94
|
+
function parseTriples(triplesStr, baseUri, prefixes = {}) {
|
|
95
|
+
const triples = [];
|
|
96
|
+
|
|
97
|
+
// Build prefix declarations for N3 parser
|
|
98
|
+
let turtleDoc = '';
|
|
99
|
+
for (const [prefix, uri] of Object.entries(prefixes)) {
|
|
100
|
+
turtleDoc += `@prefix ${prefix}: <${uri}> .\n`;
|
|
101
|
+
}
|
|
102
|
+
turtleDoc += `@base <${baseUri}> .\n`;
|
|
103
|
+
turtleDoc += triplesStr;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const parser = new Parser({ baseIRI: baseUri });
|
|
107
|
+
const quads = parser.parse(turtleDoc);
|
|
108
|
+
|
|
109
|
+
for (const q of quads) {
|
|
110
|
+
triples.push({
|
|
111
|
+
subject: termToValue(q.subject, baseUri),
|
|
112
|
+
predicate: termToValue(q.predicate, baseUri),
|
|
113
|
+
object: termToJsonLdValue(q.object, baseUri)
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// If N3 parsing fails, try simple regex parsing for basic patterns
|
|
118
|
+
const simpleTriples = parseSimpleTriples(triplesStr, baseUri, prefixes);
|
|
119
|
+
triples.push(...simpleTriples);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return triples;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Convert N3 term to a string value
|
|
127
|
+
*/
|
|
128
|
+
function termToValue(term, baseUri) {
|
|
129
|
+
if (term.termType === 'NamedNode') {
|
|
130
|
+
return term.value;
|
|
131
|
+
} else if (term.termType === 'BlankNode') {
|
|
132
|
+
return `_:${term.value}`;
|
|
133
|
+
}
|
|
134
|
+
return term.value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Convert N3 term to JSON-LD value format
|
|
139
|
+
*/
|
|
140
|
+
function termToJsonLdValue(term, baseUri) {
|
|
141
|
+
if (term.termType === 'NamedNode') {
|
|
142
|
+
return { '@id': term.value };
|
|
143
|
+
} else if (term.termType === 'BlankNode') {
|
|
144
|
+
return { '@id': `_:${term.value}` };
|
|
145
|
+
} else if (term.termType === 'Literal') {
|
|
146
|
+
if (term.datatype && term.datatype.value !== 'http://www.w3.org/2001/XMLSchema#string') {
|
|
147
|
+
return {
|
|
148
|
+
'@value': term.value,
|
|
149
|
+
'@type': term.datatype.value
|
|
150
|
+
};
|
|
151
|
+
} else if (term.language) {
|
|
152
|
+
return {
|
|
153
|
+
'@value': term.value,
|
|
154
|
+
'@language': term.language
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return term.value;
|
|
158
|
+
}
|
|
159
|
+
return term.value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Simple regex-based triple parsing as fallback
|
|
164
|
+
*/
|
|
165
|
+
function parseSimpleTriples(triplesStr, baseUri, prefixes) {
|
|
166
|
+
const triples = [];
|
|
167
|
+
|
|
168
|
+
// Very basic pattern matching for simple cases
|
|
169
|
+
// Format: <subject> <predicate> <object> .
|
|
170
|
+
// or: <subject> <predicate> "literal" .
|
|
171
|
+
const lines = triplesStr.split(/\.\s*/).filter(l => l.trim());
|
|
172
|
+
|
|
173
|
+
for (const line of lines) {
|
|
174
|
+
const parts = line.trim().match(/^(<[^>]+>|[\w:]+)\s+(<[^>]+>|[\w:]+)\s+(.+)$/);
|
|
175
|
+
if (parts) {
|
|
176
|
+
const subject = expandUri(parts[1].trim(), baseUri, prefixes);
|
|
177
|
+
const predicate = expandUri(parts[2].trim(), baseUri, prefixes);
|
|
178
|
+
const objectStr = parts[3].trim();
|
|
179
|
+
|
|
180
|
+
let object;
|
|
181
|
+
if (objectStr.startsWith('<') && objectStr.endsWith('>')) {
|
|
182
|
+
object = { '@id': expandUri(objectStr, baseUri, prefixes) };
|
|
183
|
+
} else if (objectStr.startsWith('"')) {
|
|
184
|
+
// Parse literal
|
|
185
|
+
const literalMatch = objectStr.match(/^"([^"]*)"(?:@(\w+)|\^\^<([^>]+)>)?/);
|
|
186
|
+
if (literalMatch) {
|
|
187
|
+
if (literalMatch[2]) {
|
|
188
|
+
object = { '@value': literalMatch[1], '@language': literalMatch[2] };
|
|
189
|
+
} else if (literalMatch[3]) {
|
|
190
|
+
object = { '@value': literalMatch[1], '@type': literalMatch[3] };
|
|
191
|
+
} else {
|
|
192
|
+
object = literalMatch[1];
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
object = objectStr.replace(/^"|"$/g, '');
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
// Prefixed name
|
|
199
|
+
object = { '@id': expandUri(objectStr, baseUri, prefixes) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
triples.push({ subject, predicate, object });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return triples;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Expand a prefixed name or relative URI
|
|
211
|
+
*/
|
|
212
|
+
function expandUri(uri, baseUri, prefixes) {
|
|
213
|
+
if (uri.startsWith('<') && uri.endsWith('>')) {
|
|
214
|
+
const inner = uri.slice(1, -1);
|
|
215
|
+
if (inner.startsWith('http://') || inner.startsWith('https://')) {
|
|
216
|
+
return inner;
|
|
217
|
+
}
|
|
218
|
+
// Relative URI
|
|
219
|
+
return new URL(inner, baseUri).href;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Check for prefixed name
|
|
223
|
+
const colonIndex = uri.indexOf(':');
|
|
224
|
+
if (colonIndex > 0) {
|
|
225
|
+
const prefix = uri.substring(0, colonIndex);
|
|
226
|
+
const local = uri.substring(colonIndex + 1);
|
|
227
|
+
if (prefixes[prefix]) {
|
|
228
|
+
return prefixes[prefix] + local;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return uri;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Apply SPARQL Update to a JSON-LD document
|
|
237
|
+
* @param {object} document - The JSON-LD document
|
|
238
|
+
* @param {{ inserts: Array, deletes: Array }} update - Parsed SPARQL Update
|
|
239
|
+
* @param {string} baseUri - Base URI of the document
|
|
240
|
+
* @returns {object} Updated document
|
|
241
|
+
*/
|
|
242
|
+
export function applySparqlUpdate(document, update, baseUri) {
|
|
243
|
+
// Clone the document
|
|
244
|
+
let doc = JSON.parse(JSON.stringify(document));
|
|
245
|
+
|
|
246
|
+
// Ensure document is in a workable format
|
|
247
|
+
if (!Array.isArray(doc)) {
|
|
248
|
+
doc = [doc];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Apply deletes
|
|
252
|
+
for (const triple of update.deletes) {
|
|
253
|
+
doc = deleteTriple(doc, triple, baseUri);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Apply inserts
|
|
257
|
+
for (const triple of update.inserts) {
|
|
258
|
+
doc = insertTriple(doc, triple, baseUri);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Return single object if only one node
|
|
262
|
+
if (Array.isArray(doc) && doc.length === 1) {
|
|
263
|
+
return doc[0];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return doc;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Delete a triple from a JSON-LD document
|
|
271
|
+
*/
|
|
272
|
+
function deleteTriple(doc, triple, baseUri) {
|
|
273
|
+
const subjectId = resolveSubjectId(triple.subject, baseUri);
|
|
274
|
+
|
|
275
|
+
for (const node of doc) {
|
|
276
|
+
const nodeId = node['@id'] || '';
|
|
277
|
+
if (matchesSubject(nodeId, subjectId, baseUri)) {
|
|
278
|
+
// Found the subject node, remove the predicate-object pair
|
|
279
|
+
if (node[triple.predicate]) {
|
|
280
|
+
const values = Array.isArray(node[triple.predicate])
|
|
281
|
+
? node[triple.predicate]
|
|
282
|
+
: [node[triple.predicate]];
|
|
283
|
+
|
|
284
|
+
const filtered = values.filter(v => !valuesMatch(v, triple.object));
|
|
285
|
+
|
|
286
|
+
if (filtered.length === 0) {
|
|
287
|
+
delete node[triple.predicate];
|
|
288
|
+
} else if (filtered.length === 1) {
|
|
289
|
+
node[triple.predicate] = filtered[0];
|
|
290
|
+
} else {
|
|
291
|
+
node[triple.predicate] = filtered;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return doc;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Insert a triple into a JSON-LD document
|
|
302
|
+
*/
|
|
303
|
+
function insertTriple(doc, triple, baseUri) {
|
|
304
|
+
const subjectId = resolveSubjectId(triple.subject, baseUri);
|
|
305
|
+
|
|
306
|
+
// Find existing node for subject
|
|
307
|
+
let targetNode = null;
|
|
308
|
+
for (const node of doc) {
|
|
309
|
+
const nodeId = node['@id'] || '';
|
|
310
|
+
if (matchesSubject(nodeId, subjectId, baseUri)) {
|
|
311
|
+
targetNode = node;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Create new node if not found
|
|
317
|
+
if (!targetNode) {
|
|
318
|
+
targetNode = { '@id': subjectId };
|
|
319
|
+
doc.push(targetNode);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Add the predicate-object pair
|
|
323
|
+
if (targetNode[triple.predicate]) {
|
|
324
|
+
const existing = targetNode[triple.predicate];
|
|
325
|
+
if (Array.isArray(existing)) {
|
|
326
|
+
if (!existing.some(v => valuesMatch(v, triple.object))) {
|
|
327
|
+
existing.push(triple.object);
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
if (!valuesMatch(existing, triple.object)) {
|
|
331
|
+
targetNode[triple.predicate] = [existing, triple.object];
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
targetNode[triple.predicate] = triple.object;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return doc;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Resolve subject ID, handling relative URIs and hash URIs
|
|
343
|
+
*/
|
|
344
|
+
function resolveSubjectId(subject, baseUri) {
|
|
345
|
+
if (subject.startsWith('#')) {
|
|
346
|
+
return baseUri + subject;
|
|
347
|
+
}
|
|
348
|
+
if (subject.startsWith('_:')) {
|
|
349
|
+
return subject;
|
|
350
|
+
}
|
|
351
|
+
if (!subject.includes('://')) {
|
|
352
|
+
return new URL(subject, baseUri).href;
|
|
353
|
+
}
|
|
354
|
+
return subject;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Check if a node ID matches a subject
|
|
359
|
+
*/
|
|
360
|
+
function matchesSubject(nodeId, subjectId, baseUri) {
|
|
361
|
+
if (nodeId === subjectId) return true;
|
|
362
|
+
|
|
363
|
+
// Handle hash URIs
|
|
364
|
+
if (subjectId.startsWith('#') && nodeId === baseUri + subjectId) return true;
|
|
365
|
+
if (nodeId.startsWith('#') && subjectId === baseUri + nodeId) return true;
|
|
366
|
+
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Check if two JSON-LD values match
|
|
372
|
+
*/
|
|
373
|
+
function valuesMatch(a, b) {
|
|
374
|
+
if (typeof a === 'string' && typeof b === 'string') {
|
|
375
|
+
return a === b;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (typeof a === 'object' && typeof b === 'object') {
|
|
379
|
+
// Compare @id
|
|
380
|
+
if (a['@id'] && b['@id']) {
|
|
381
|
+
return a['@id'] === b['@id'];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Compare @value
|
|
385
|
+
if (a['@value'] !== undefined && b['@value'] !== undefined) {
|
|
386
|
+
return a['@value'] === b['@value'] &&
|
|
387
|
+
a['@type'] === b['@type'] &&
|
|
388
|
+
a['@language'] === b['@language'];
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Mixed string/object comparison
|
|
393
|
+
if (typeof a === 'string' && typeof b === 'object' && b['@value']) {
|
|
394
|
+
return a === b['@value'];
|
|
395
|
+
}
|
|
396
|
+
if (typeof b === 'string' && typeof a === 'object' && a['@value']) {
|
|
397
|
+
return b === a['@value'];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
401
|
+
}
|