@origintrail-official/dkg-query 10.0.13 → 10.0.15

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.
Files changed (49) hide show
  1. package/dist/caller-abort.d.ts +8 -0
  2. package/dist/caller-abort.d.ts.map +1 -0
  3. package/dist/caller-abort.js +21 -0
  4. package/dist/caller-abort.js.map +1 -0
  5. package/dist/caller-sparql-error.d.ts +46 -0
  6. package/dist/caller-sparql-error.d.ts.map +1 -0
  7. package/dist/caller-sparql-error.js +43 -0
  8. package/dist/caller-sparql-error.js.map +1 -0
  9. package/dist/dkg-query-engine.d.ts +15 -47
  10. package/dist/dkg-query-engine.d.ts.map +1 -1
  11. package/dist/dkg-query-engine.js +130 -1742
  12. package/dist/dkg-query-engine.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/query-handler.d.ts.map +1 -1
  18. package/dist/query-handler.js +68 -29
  19. package/dist/query-handler.js.map +1 -1
  20. package/dist/query-types.d.ts +27 -5
  21. package/dist/query-types.d.ts.map +1 -1
  22. package/dist/scoped-content-graph-discovery-memo.d.ts +25 -0
  23. package/dist/scoped-content-graph-discovery-memo.d.ts.map +1 -0
  24. package/dist/scoped-content-graph-discovery-memo.js +56 -0
  25. package/dist/scoped-content-graph-discovery-memo.js.map +1 -0
  26. package/dist/scoped-query-error.d.ts +4 -0
  27. package/dist/scoped-query-error.d.ts.map +1 -0
  28. package/dist/scoped-query-error.js +7 -0
  29. package/dist/scoped-query-error.js.map +1 -0
  30. package/dist/sparql-graph-scope.d.ts +36 -12
  31. package/dist/sparql-graph-scope.d.ts.map +1 -1
  32. package/dist/sparql-graph-scope.js +442 -141
  33. package/dist/sparql-graph-scope.js.map +1 -1
  34. package/dist/sparql-guard.d.ts +2 -1
  35. package/dist/sparql-guard.d.ts.map +1 -1
  36. package/dist/sparql-guard.js.map +1 -1
  37. package/dist/sparql-min-trust.d.ts +11 -0
  38. package/dist/sparql-min-trust.d.ts.map +1 -0
  39. package/dist/sparql-min-trust.js +181 -0
  40. package/dist/sparql-min-trust.js.map +1 -0
  41. package/dist/sparql-rewrite-result.d.ts +16 -0
  42. package/dist/sparql-rewrite-result.d.ts.map +1 -0
  43. package/dist/sparql-rewrite-result.js +7 -0
  44. package/dist/sparql-rewrite-result.js.map +1 -0
  45. package/package.json +4 -3
  46. package/dist/sparql-utils.d.ts +0 -18
  47. package/dist/sparql-utils.d.ts.map +0 -1
  48. package/dist/sparql-utils.js +0 -216
  49. package/dist/sparql-utils.js.map +0 -1
@@ -1,14 +1,16 @@
1
+ import { asGraphWriteRevisionSource, isSparqlHttpResponseError, } from '@origintrail-official/dkg-storage';
1
2
  import { ExactGraphReadError, GraphManager, readExactGraphPaged, resolveGraphScopedOrLegacyMetadata, } from '@origintrail-official/dkg-storage';
2
- import { contextGraphDataUri, contextGraphSharedMemoryUri, contextGraphVerifiableMemoryUri, contextGraphAssertionUri, MemoryLayer, contextGraphLayerUriCandidates, contextGraphLayerPrefixCandidates, contextGraphSubGraphUri, contextGraphMetaUri, contextGraphSharedMemoryMetaUri, assertionLifecycleUri, contextGraphSubGraphMetaUri, contextGraphPrivateUri, contextGraphSubGraphPrivateUri, assertSafeIri, escapeSparqlLiteral, validateSubGraphName, ASSERTION_NAMED_GRAPH_PREFIX, isAssertionScopedChildGraph, REMOVED_VIEWS, TrustLevel, TRUST_LEVEL_PREDICATE, GRAPH_KA_CONTENT_SCOPE_VERSION, createGraphKnowledgeAssetScope, knowledgeAssetLayerGraphUri, buildLegacyKnowledgeAssetMetadataQuery, } from '@origintrail-official/dkg-core';
3
+ import { materializePreparedSparql, prepareSparql, } from '@origintrail-official/dkg-rdf-utils/sparql';
4
+ import { contextGraphDataUri, contextGraphSharedMemoryUri, contextGraphVerifiableMemoryUri, contextGraphAssertionUri, MemoryLayer, contextGraphLayerUriCandidates, contextGraphLayerPrefixCandidates, contextGraphSubGraphUri, contextGraphMetaUri, contextGraphSharedMemoryMetaUri, assertionLifecycleUri, contextGraphSubGraphMetaUri, contextGraphPrivateUri, contextGraphSubGraphPrivateUri, assertSafeIri, escapeSparqlLiteral, validateSubGraphName, ASSERTION_NAMED_GRAPH_PREFIX, isAssertionScopedChildGraph, REMOVED_VIEWS, TrustLevel, GRAPH_KA_CONTENT_SCOPE_VERSION, createGraphKnowledgeAssetScope, knowledgeAssetLayerGraphUri, buildLegacyKnowledgeAssetMetadataQuery, } from '@origintrail-official/dkg-core';
3
5
  import { validateReadOnlySparql, emptyResultForSparql, detectSparqlQueryForm, } from './sparql-guard.js';
4
- import { findMatchingSparqlCloseBrace as findMatchingCloseBrace, isSparqlKeyword, isSparqlKeywordStart as isKeywordStart, isSparqlWordContinuation as isWordContinuation, readSparqlVariable, skipSparqlStringLiteral as scanSparqlStringLiteral, skipSparqlIriRef, skipSparqlSpaceAndLineComments, stripLiteralsAndComments, } from './sparql-utils.js';
5
- import { callerGraphValuesAreAuthorized, collectPrefixDeclarations, readSparqlPrefixName, resolveSparqlPrefixedName, } from './sparql-graph-scope.js';
6
- export class ScopedQueryViolationError extends Error {
7
- constructor(message) {
8
- super(`Scoped query violation: ${message}`);
9
- this.name = 'ScopedQueryViolationError';
10
- }
11
- }
6
+ import { assertExplicitGraphIrisAllowed, assertNoCallerDatasetClauses, constrainGraphVariablesToAllowedSet, prepareGraphScope, requireGraphScopeRewrite, rewriteGraphRoute, rewriteGraphSet, transitionGraphScope, wrapWithGraph, } from './sparql-graph-scope.js';
7
+ import { injectMinTrustFilter } from './sparql-min-trust.js';
8
+ import { CallerSparqlRejectedError } from './caller-sparql-error.js';
9
+ import { raceAgainstCallerAbort } from './caller-abort.js';
10
+ import { ScopedContentGraphDiscoveryMemo } from './scoped-content-graph-discovery-memo.js';
11
+ export { ScopedQueryViolationError } from './scoped-query-error.js';
12
+ /** Upstream statuses that mean the SUBMITTED query was malformed. */
13
+ const MALFORMED_CALLER_QUERY_STATUSES = new Set([400, 422]);
12
14
  function storeOptions(options) {
13
15
  if (!options?.signal && !options?.priority && !options?.source)
14
16
  return undefined;
@@ -49,24 +51,6 @@ function createQueryStoreReadContext(store, queryOptions) {
49
51
  },
50
52
  };
51
53
  }
52
- function raceAgainstCallerAbort(work, signal) {
53
- if (!signal)
54
- return work;
55
- if (signal.aborted) {
56
- const reason = signal.reason;
57
- return Promise.reject(reason instanceof Error ? reason : new Error(String(reason ?? 'aborted')));
58
- }
59
- return new Promise((resolve, reject) => {
60
- const onAbort = () => {
61
- const reason = signal.reason;
62
- reject(reason instanceof Error ? reason : new Error(String(reason ?? 'aborted')));
63
- };
64
- signal.addEventListener('abort', onAbort, { once: true });
65
- void work.then(resolve, reject).finally(() => {
66
- signal.removeEventListener('abort', onAbort);
67
- });
68
- });
69
- }
70
54
  /**
71
55
  * Resolves a V10 GetView + context graph ID to the named-graph URIs (or
72
56
  * prefixes) that the query engine should target.
@@ -228,23 +212,34 @@ export function resolveViewGraphs(view, contextGraphId, opts) {
228
212
  export class DKGQueryEngine {
229
213
  store;
230
214
  graphManager;
231
- // Collapse the dashboard's parallel WM/SWM/VM count scans without retaining
232
- // a completed allow-list that could miss newly-created assertion graphs.
233
- scopedContentGraphAllowListInFlight = new Map();
215
+ scopedContentGraphDiscoveryMemo;
234
216
  constructor(store) {
235
217
  this.store = store;
236
218
  this.graphManager = new GraphManager(store);
219
+ this.scopedContentGraphDiscoveryMemo = new ScopedContentGraphDiscoveryMemo(asGraphWriteRevisionSource(store));
237
220
  }
238
221
  async query(sparql, options) {
222
+ const prepared = prepareSparql(sparql);
223
+ if (prepared.status === 'malformed-uchar') {
224
+ throw new Error('SPARQL rejected: malformed Unicode code-point escape');
225
+ }
226
+ if (prepared.wordTokens.has('SERVICE')) {
227
+ throw new Error('SPARQL rejected: SERVICE clauses are not allowed');
228
+ }
239
229
  const reads = createQueryStoreReadContext(this.store, options);
240
- const guard = validateReadOnlySparql(sparql);
230
+ const guard = validateReadOnlySparql(prepared);
241
231
  if (!guard.safe) {
242
232
  throw new Error(`SPARQL rejected: ${guard.reason}`);
243
233
  }
234
+ // Policy checks and rewrites retain the prepared source/logical views.
235
+ // Active UCHAR syntax is materialized once, immediately before the final
236
+ // caller query crosses the store boundary.
237
+ const initialGraphScope = prepareGraphScope(prepared);
238
+ let routedScope = initialGraphScope;
244
239
  // ── V10 view-based routing ────────────────────────────────────────
245
240
  const effectiveContextGraphId = options?.contextGraphId;
246
241
  if (effectiveContextGraphId) {
247
- assertNoCallerDatasetClauses(sparql);
242
+ assertNoCallerDatasetClauses(initialGraphScope);
248
243
  }
249
244
  if (options?.subGraphName) {
250
245
  const v = validateSubGraphName(options.subGraphName);
@@ -342,7 +337,8 @@ export class DKGQueryEngine {
342
337
  ]
343
338
  : [];
344
339
  const explicitAllowedGraphs = [...allowedGraphs, ...metaAllowList, ...privateAllowList];
345
- const shouldExpandGraphVariables = options?.includeContextGraphPartitions === true && collectGraphVariables(sparql).length > 0;
340
+ const shouldExpandGraphVariables = options?.includeContextGraphPartitions === true
341
+ && initialGraphScope.graphVariables.length > 0;
346
342
  const variableAllowedGraphs = shouldExpandGraphVariables
347
343
  ? await this.resolveScopedGraphVariableAllowList(effectiveContextGraphId, explicitAllowedGraphs, { subGraphName, isSwmOnlyRoute }, reads)
348
344
  : explicitAllowedGraphs;
@@ -350,8 +346,8 @@ export class DKGQueryEngine {
350
346
  // allow-list. GRAPH variables only gain known same-CG content
351
347
  // partitions for callers that explicitly opt into broad count scans;
352
348
  // legacy scoped routes keep their selected memory-layer contract.
353
- assertExplicitGraphIrisAllowed(sparql, explicitAllowedGraphs);
354
- sparql = constrainGraphVariablesToAllowedSet(sparql, variableAllowedGraphs);
349
+ assertExplicitGraphIrisAllowed(initialGraphScope, explicitAllowedGraphs);
350
+ routedScope = requireGraphScopeRewrite(constrainGraphVariablesToAllowedSet(initialGraphScope, variableAllowedGraphs));
355
351
  }
356
352
  if (options?.view) {
357
353
  if (!effectiveContextGraphId) {
@@ -365,10 +361,10 @@ export class DKGQueryEngine {
365
361
  if (!v.valid)
366
362
  throw new Error(v.reason);
367
363
  }
368
- return this.queryWithView(sparql, options.view, effectiveContextGraphId, options, reads);
364
+ return this.queryWithView(initialGraphScope, options.view, effectiveContextGraphId, options, reads);
369
365
  }
370
366
  // ── Legacy routing (V9 compat) ────────────────────────────────────
371
- let effectiveSparql = sparql;
367
+ let effectiveScope = routedScope;
372
368
  if (effectiveContextGraphId) {
373
369
  const dataGraph = options?.subGraphName
374
370
  ? contextGraphSubGraphUri(effectiveContextGraphId, options.subGraphName)
@@ -377,37 +373,31 @@ export class DKGQueryEngine {
377
373
  if (options?.includeSharedMemory ?? options?.includeWorkspace) {
378
374
  // Per-KA VM: read-both the published per-KA …/_verifiable_memory/{addr}/{number} + root.
379
375
  const vmGraphsInc = await this.discoverGraphsByPrefix(`${dataGraph}/_verifiable_memory/`, reads);
380
- const dataSparql = vmGraphsInc.length > 0
381
- ? (this.wrapVerifiableMemoryGraphSet(sparql, [dataGraph, ...vmGraphsInc])
382
- ?? wrapWithGraph(sparql, dataGraph))
383
- : wrapWithGraph(sparql, dataGraph);
376
+ const dataScope = requireGraphScopeRewrite(rewriteGraphRoute(routedScope, vmGraphsInc.length > 0 ? [dataGraph, ...vmGraphsInc] : [], dataGraph, 'deduplicated-values-union'));
384
377
  // Per-KA SWM: union the discovered …/_shared_memory/{addr}/{number} graphs.
385
378
  const swmGraphs = await this.discoverGraphsByPrefix(`${sharedMemoryGraph}/`, reads);
386
- const sharedMemorySparql = swmGraphs.length > 0
387
- ? (wrapWithGraphUnion(sparql, swmGraphs) ?? wrapWithGraph(sparql, sharedMemoryGraph))
388
- : wrapWithGraph(sparql, sharedMemoryGraph);
389
- const dataResult = await reads.query(dataSparql);
390
- const smResult = await reads.query(sharedMemorySparql);
379
+ const sharedMemoryScope = requireGraphScopeRewrite(rewriteGraphRoute(routedScope, swmGraphs, sharedMemoryGraph, 'union-only'));
380
+ // Both are graph-wrapped forms of the CALLER's query, so they carry
381
+ // the same provenance as execAndNormalize (PR #2330 review — this
382
+ // branch previously bypassed the marker, leaving the original 500 for
383
+ // any `includeSharedMemory` request with malformed SPARQL).
384
+ const dataResult = await this.execCallerQuery(dataScope, reads);
385
+ const smResult = await this.execCallerQuery(sharedMemoryScope, reads);
391
386
  return mergeSharedMemoryAndDataResults(dataResult, smResult);
392
387
  }
393
388
  if (options?.graphSuffix === '_shared_memory') {
394
389
  // Uniform layout: SWM is per-KA …/_shared_memory/{addr}/{number}. Discover the
395
390
  // per-KA graphs under the prefix and union them (the legacy bucket is now empty).
396
391
  const swmGraphs = await this.discoverGraphsByPrefix(`${sharedMemoryGraph}/`, reads);
397
- effectiveSparql = swmGraphs.length > 0
398
- ? (wrapWithGraphUnion(sparql, swmGraphs) ?? wrapWithGraph(sparql, sharedMemoryGraph))
399
- : wrapWithGraph(sparql, sharedMemoryGraph);
392
+ effectiveScope = requireGraphScopeRewrite(rewriteGraphRoute(routedScope, swmGraphs, sharedMemoryGraph, 'union-only'));
400
393
  }
401
394
  else {
402
395
  // Per-KA VM: read-both the published per-KA …/_verifiable_memory/{addr}/{number} + root.
403
396
  const vmGraphs = await this.discoverGraphsByPrefix(`${dataGraph}/_verifiable_memory/`, reads);
404
- effectiveSparql = vmGraphs.length > 0
405
- ? (this.wrapVerifiableMemoryGraphSet(sparql, [dataGraph, ...vmGraphs])
406
- ?? wrapWithGraph(sparql, dataGraph))
407
- : wrapWithGraph(sparql, dataGraph);
397
+ effectiveScope = requireGraphScopeRewrite(rewriteGraphRoute(routedScope, vmGraphs.length > 0 ? [dataGraph, ...vmGraphs] : [], dataGraph, 'deduplicated-values-union'));
408
398
  }
409
399
  }
410
- const result = await this.execAndNormalize(effectiveSparql, reads);
400
+ const result = await this.execAndNormalize(effectiveScope, reads);
411
401
  // Strip results originating from excluded graphs (e.g. private CGs).
412
402
  if (options?.excludeGraphPrefixes?.length && result.bindings.length > 0) {
413
403
  return this.filterExcludedGraphs(result, options.excludeGraphPrefixes);
@@ -437,7 +427,8 @@ export class DKGQueryEngine {
437
427
  /**
438
428
  * Execute a SPARQL query scoped to a declared V10 state view.
439
429
  */
440
- async queryWithView(sparql, view, contextGraphId, options, reads) {
430
+ async queryWithView(initialScope, view, contextGraphId, options, reads) {
431
+ const sparql = initialScope.source;
441
432
  // Uniform layout (rc.17): a by-name working-memory read must target the per-KA
442
433
  // graph `…/_working_memory/{addr}/{number}` the data was written to. Resolve the
443
434
  // KA number from the `dkg:kaId` stamped on the lifecycle URN in `_meta` and pass
@@ -523,8 +514,8 @@ export class DKGQueryEngine {
523
514
  // `quads: []`. Delegate to the shared kind-aware empty-result helper.
524
515
  return emptyResultForSparql(sparql);
525
516
  }
526
- assertExplicitGraphIrisAllowed(sparql, allGraphs);
527
- sparql = constrainGraphVariablesToAllowedSet(sparql, allGraphs);
517
+ assertExplicitGraphIrisAllowed(initialScope, allGraphs);
518
+ const constrainedScope = requireGraphScopeRewrite(constrainGraphVariablesToAllowedSet(initialScope, allGraphs));
528
519
  // Spec §14 trust-gradient filter — only enforced on verifiable-memory
529
520
  // where on-chain-anchored trust metadata is expected to live.
530
521
  // When `minTrust` (or legacy `_minTrust`) is set, rewrite the query so
@@ -541,50 +532,41 @@ export class DKGQueryEngine {
541
532
  // rewriter MUST succeed or we fail closed — returning an empty result
542
533
  // is the correct behaviour for "no subject meets the trust threshold"
543
534
  // when we cannot prove the threshold was applied.
544
- let effectiveSparql = sparql;
535
+ let effectiveScope = constrainedScope;
545
536
  const effectiveMinTrust = options.minTrust ?? options._minTrust;
546
537
  // `SelfAttested` (0) is the floor and means no trust filter is needed.
547
538
  // Endorsed and above require explicit writer-side trust metadata.
548
539
  if (view === 'verifiable-memory' &&
549
540
  effectiveMinTrust !== undefined &&
550
541
  effectiveMinTrust > TrustLevel.SelfAttested) {
551
- const rewritten = injectMinTrustFilter(sparql, effectiveMinTrust);
552
- if (!rewritten) {
542
+ const rewritten = injectMinTrustFilter(constrainedScope, effectiveMinTrust);
543
+ if (rewritten.kind === 'unsupported') {
553
544
  console.warn(`[DKGQueryEngine] minTrust=${effectiveMinTrust} requested for a query shape ` +
554
545
  `injectMinTrustFilter cannot safely rewrite; returning empty result (fail-closed)`);
555
546
  // Preserve the query form so CONSTRUCT/DESCRIBE callers see
556
547
  // `{ bindings: [], quads: [] }` rather than a shapeless deny, and
557
548
  // ASK callers see `{ bindings: [{ result: 'false' }] }`.
558
- return emptyResultForSparql(sparql);
549
+ return emptyResultForSparql(constrainedScope.source);
559
550
  }
560
- effectiveSparql = rewritten;
551
+ effectiveScope = transitionGraphScope(constrainedScope, rewritten.value);
561
552
  }
562
553
  if (allGraphs.length === 1) {
563
- return this.execAndNormalize(wrapWithGraph(effectiveSparql, allGraphs[0]), reads);
554
+ return this.execAndNormalize(requireGraphScopeRewrite(wrapWithGraph(effectiveScope, allGraphs[0])), reads);
564
555
  }
565
556
  if (view === 'verifiable-memory') {
566
- const rewritten = this.wrapVerifiableMemoryGraphSet(effectiveSparql, allGraphs);
567
- if (rewritten !== null) {
568
- return this.execAndNormalize(rewritten, reads);
557
+ const rewritten = rewriteGraphSet(effectiveScope, allGraphs, 'deduplicated-values-union');
558
+ if (rewritten.kind === 'ready') {
559
+ return this.execAndNormalize(rewritten.value, reads);
569
560
  }
570
561
  }
571
- return this.queryMultipleGraphs(effectiveSparql, allGraphs, reads);
572
- }
573
- /** Canonical graph rewrite for root + per-KA/per-cgId verifiable-memory reads. */
574
- wrapVerifiableMemoryGraphSet(sparql, graphs) {
575
- if (graphs.length === 0)
576
- return sparql;
577
- if (graphs.length === 1)
578
- return wrapWithGraph(sparql, graphs[0]);
579
- return wrapWithDeduplicatedGraphValues(sparql, graphs)
580
- ?? wrapWithGraphValues(sparql, graphs)
581
- ?? wrapWithGraphUnion(sparql, graphs);
562
+ return this.queryMultipleGraphs(effectiveScope, allGraphs, reads);
582
563
  }
583
- async queryMultipleGraphs(sparql, graphs, reads) {
564
+ async queryMultipleGraphs(scope, graphs, reads) {
565
+ const sparql = scope.source;
584
566
  if (graphs.length === 0)
585
567
  return { bindings: [] };
586
568
  if (graphs.length === 1) {
587
- return this.execAndNormalize(wrapWithGraph(sparql, graphs[0]), reads);
569
+ return this.execAndNormalize(requireGraphScopeRewrite(wrapWithGraph(scope, graphs[0])), reads);
588
570
  }
589
571
  // Prefer a single `VALUES ?g { … } GRAPH ?g { … }` query: it scopes the
590
572
  // read to the named-graph set as ONE basic graph pattern iterated over a
@@ -595,18 +577,14 @@ export class DKGQueryEngine {
595
577
  // VALUES form plans in constant depth. LIMIT/ORDER BY/DISTINCT/aggregate
596
578
  // semantics are preserved because those modifiers stay in the outer query
597
579
  // around the injected block.
598
- const valuesSparql = wrapWithGraphValues(sparql, graphs);
599
- if (valuesSparql !== null) {
600
- return this.execAndNormalize(valuesSparql, reads);
580
+ const graphSetRewrite = rewriteGraphSet(scope, graphs, 'values-union');
581
+ if (graphSetRewrite.kind === 'ready') {
582
+ return this.execAndNormalize(graphSetRewrite.value, reads);
601
583
  }
602
- // Residual shapes `wrapWithGraphValues` declines (an inner top-level
584
+ // Residual shapes the graph-set policy declines (an inner top-level
603
585
  // UNION, no locatable WHERE block, or a sentinel-variable collision) keep
604
586
  // the original union / per-graph fallback so the #789 form-aware
605
587
  // cross-graph merge below is preserved unchanged.
606
- const unionSparql = wrapWithGraphUnion(sparql, graphs);
607
- if (unionSparql !== null) {
608
- return this.execAndNormalize(unionSparql, reads);
609
- }
610
588
  // Fallback: the inner body contains a UNION so we cannot safely wrap
611
589
  // in a single query without either crashing Blazegraph (nested
612
590
  // UnionNode) or leaking a helper variable. Run per-graph and merge
@@ -623,7 +601,7 @@ export class DKGQueryEngine {
623
601
  // constructed from multiple source graphs).
624
602
  const merged = [];
625
603
  for (const g of graphs) {
626
- const r = await this.execAndNormalize(wrapWithGraph(sparql, g), reads);
604
+ const r = await this.execAndNormalize(requireGraphScopeRewrite(wrapWithGraph(scope, g)), reads);
627
605
  if (r.quads)
628
606
  merged.push(...r.quads);
629
607
  }
@@ -633,7 +611,7 @@ export class DKGQueryEngine {
633
611
  // Boolean result: true iff the pattern matches in ANY graph.
634
612
  // Short-circuit on the first positive graph.
635
613
  for (const g of graphs) {
636
- const r = await this.execAndNormalize(wrapWithGraph(sparql, g), reads);
614
+ const r = await this.execAndNormalize(requireGraphScopeRewrite(wrapWithGraph(scope, g)), reads);
637
615
  if (r.bindings[0]?.result === 'true') {
638
616
  return { bindings: [{ result: 'true' }] };
639
617
  }
@@ -648,7 +626,7 @@ export class DKGQueryEngine {
648
626
  // Rather than silently return duplicate / mis-ordered / over-limit
649
627
  // rows, reject the unsupported shape explicitly so the caller gets a
650
628
  // clear error instead of wrong data.
651
- if (hasCrossGraphUnsafeModifier(sparql)) {
629
+ if (hasCrossGraphUnsafeModifier(scope)) {
652
630
  throw new Error('Multi-graph query combines an inner UNION with a solution-set ' +
653
631
  'modifier (DISTINCT/ORDER BY/LIMIT/OFFSET/GROUP BY/aggregate). ' +
654
632
  'This shape cannot be evaluated across graphs without corrupting ' +
@@ -657,7 +635,7 @@ export class DKGQueryEngine {
657
635
  }
658
636
  const all = [];
659
637
  for (const g of graphs) {
660
- const r = await this.execAndNormalize(wrapWithGraph(sparql, g), reads);
638
+ const r = await this.execAndNormalize(requireGraphScopeRewrite(wrapWithGraph(scope, g)), reads);
661
639
  all.push(...r.bindings);
662
640
  }
663
641
  return { bindings: all };
@@ -783,23 +761,15 @@ export class DKGQueryEngine {
783
761
  return [...allowed];
784
762
  }
785
763
  async resolveScopedContentGraphAllowList(contextGraphId, reads, subGraphName) {
786
- const key = JSON.stringify([
787
- contextGraphId,
788
- subGraphName ?? null,
789
- reads.shared.cacheKey,
790
- ]);
791
- const cached = this.scopedContentGraphAllowListInFlight.get(key);
792
- if (cached) {
793
- return raceAgainstCallerAbort(cached, reads.signal);
794
- }
795
- const promise = this.discoverScopedContentGraphAllowList(contextGraphId, reads.shared, subGraphName);
796
- this.scopedContentGraphAllowListInFlight.set(key, promise);
797
- void promise.finally(() => {
798
- if (this.scopedContentGraphAllowListInFlight.get(key) === promise) {
799
- this.scopedContentGraphAllowListInFlight.delete(key);
800
- }
801
- }).catch(() => undefined);
802
- return raceAgainstCallerAbort(promise, reads.signal);
764
+ const contentKey = JSON.stringify([contextGraphId, subGraphName ?? null]);
765
+ const graphPrefix = `did:dkg:context-graph:${contextGraphId}`;
766
+ return this.scopedContentGraphDiscoveryMemo.get({
767
+ contentKey,
768
+ laneKey: reads.shared.cacheKey,
769
+ graphPrefix,
770
+ signal: reads.signal,
771
+ load: () => this.discoverScopedContentGraphAllowList(contextGraphId, reads.shared, subGraphName),
772
+ });
803
773
  }
804
774
  async discoverScopedContentGraphAllowList(contextGraphId, reads, subGraphName) {
805
775
  const allowed = new Set();
@@ -877,11 +847,35 @@ export class DKGQueryEngine {
877
847
  }
878
848
  return uris;
879
849
  }
880
- async execAndNormalize(sparql, reads) {
881
- const result = await reads.query(sparql);
850
+ /**
851
+ * Execute CALLER-derived SPARQL and mark a store rejection with provenance.
852
+ *
853
+ * GH#1758 — every caller-derived store execution must go through here.
854
+ * Engine-generated queries (access checks, graph resolution, metadata scans)
855
+ * deliberately do NOT, so the HTTP boundary can answer 400 for a malformed
856
+ * caller query without blaming the caller when the configured backend
857
+ * rejects an internal one.
858
+ *
859
+ * Only 400/422 are translated: 401/403/404/429 and 5xx mean the store
860
+ * rejected US and stay server faults (PR #2330 review).
861
+ */
862
+ async execCallerQuery(scope, reads) {
863
+ const sparql = materializePreparedSparql(scope.prepared);
864
+ try {
865
+ return await reads.query(sparql);
866
+ }
867
+ catch (err) {
868
+ if (isSparqlHttpResponseError(err) && MALFORMED_CALLER_QUERY_STATUSES.has(err.status)) {
869
+ throw new CallerSparqlRejectedError(err.message, err.status, { cause: err });
870
+ }
871
+ throw err;
872
+ }
873
+ }
874
+ async execAndNormalize(scope, reads) {
875
+ const result = await this.execCallerQuery(scope, reads);
882
876
  if (result.type === 'bindings') {
883
877
  if (result.bindings.length === 0) {
884
- const empty = emptyResultForSparql(sparql);
878
+ const empty = emptyResultForSparql(scope.source);
885
879
  if (empty.quads !== undefined)
886
880
  return empty;
887
881
  }
@@ -1108,11 +1102,6 @@ function parseCanonicalIntegerBinding(value) {
1108
1102
  return undefined;
1109
1103
  }
1110
1104
  }
1111
- function assertNoCallerDatasetClauses(sparql) {
1112
- if (hasCallerDatasetClause(sparql)) {
1113
- throw new ScopedQueryViolationError('FROM clauses are not allowed on scoped local queries');
1114
- }
1115
- }
1116
1105
  async function listGraphsByPrefix(store, prefix, options) {
1117
1106
  return store.listGraphsByPrefix
1118
1107
  ? store.listGraphsByPrefix(prefix, options)
@@ -1125,1610 +1114,6 @@ async function listGraphFamily(store, rootGraph, options) {
1125
1114
  }
1126
1115
  return graphs;
1127
1116
  }
1128
- function hasCallerDatasetClause(sparql) {
1129
- const n = sparql.length;
1130
- let i = 0;
1131
- while (i < n) {
1132
- const ch = sparql[i];
1133
- if (ch === '#') {
1134
- while (i < n && sparql[i] !== '\n')
1135
- i++;
1136
- continue;
1137
- }
1138
- if (ch === '"' || ch === "'") {
1139
- i = skipSparqlStringLiteral(sparql, i);
1140
- continue;
1141
- }
1142
- if (ch === '<') {
1143
- const end = skipSparqlIriRef(sparql, i);
1144
- i = end ?? i + 1;
1145
- continue;
1146
- }
1147
- if (isKeywordStart(sparql, i)) {
1148
- let j = i + 1;
1149
- while (j < n && isWordContinuation(sparql[j]))
1150
- j++;
1151
- if (isSparqlKeyword(sparql, i, j, 'FROM')) {
1152
- return true;
1153
- }
1154
- i = j;
1155
- continue;
1156
- }
1157
- i++;
1158
- }
1159
- return false;
1160
- }
1161
- function assertExplicitGraphIrisAllowed(sparql, allowedGraphs) {
1162
- const allowed = new Set(allowedGraphs);
1163
- for (const graphIri of collectExplicitGraphIris(sparql)) {
1164
- if (!allowed.has(graphIri)) {
1165
- throw new ScopedQueryViolationError(`GRAPH <${graphIri}> is outside the allowed graph set`);
1166
- }
1167
- }
1168
- }
1169
- function readGraphTarget(sparql, start, prefixes) {
1170
- const variable = readSparqlVariable(sparql, start);
1171
- if (variable) {
1172
- return { kind: 'variable', end: start + variable.length };
1173
- }
1174
- if (sparql[start] === '<') {
1175
- const iriEnd = skipSparqlIriRef(sparql, start);
1176
- if (!iriEnd) {
1177
- throw new ScopedQueryViolationError('GRAPH target must be a variable, explicit IRI, or resolvable prefixed name on scoped queries');
1178
- }
1179
- return {
1180
- kind: 'iri',
1181
- iri: sparql.slice(start + 1, iriEnd - 1),
1182
- end: iriEnd,
1183
- };
1184
- }
1185
- const prefixedName = readSparqlPrefixName(sparql, start);
1186
- if (prefixedName) {
1187
- const iri = resolveSparqlPrefixedName(prefixedName, prefixes);
1188
- if (!iri) {
1189
- throw new ScopedQueryViolationError(`GRAPH prefixed target ${sparql.slice(start, start + prefixedName.length)} cannot be resolved from PREFIX declarations`);
1190
- }
1191
- return {
1192
- kind: 'iri',
1193
- iri,
1194
- end: start + prefixedName.length,
1195
- };
1196
- }
1197
- return null;
1198
- }
1199
- function constrainGraphVariablesToAllowedSet(sparql, allowedGraphs) {
1200
- if (hasNestedSelectWithGraphVariable(sparql)) {
1201
- throw new ScopedQueryViolationError('GRAPH variables inside nested SELECT subqueries cannot be constrained safely');
1202
- }
1203
- const graphVariables = collectGraphVariables(sparql);
1204
- if (graphVariables.length === 0)
1205
- return sparql;
1206
- const braceStart = findWhereBraceStart(sparql);
1207
- if (braceStart === -1) {
1208
- throw new ScopedQueryViolationError('GRAPH variables cannot be constrained because the WHERE block could not be located');
1209
- }
1210
- assertGraphVariablesAreTopLevel(sparql, braceStart);
1211
- assertNoTopLevelDefaultGraphPatternsWithGraphVariables(sparql, braceStart);
1212
- const allowed = new Set(allowedGraphs);
1213
- const variablesNeedingConstraint = graphVariables.filter((variable) => {
1214
- // A top-level `VALUES ?g { <g1> ... }` already constrains every use of
1215
- // `GRAPH ?g` in this group. When its values are all inside the DKG
1216
- // allow-list, injecting the complete allow-list again is redundant and can
1217
- // be catastrophic at scale: the Blackbox VM reader supplied five exact
1218
- // partitions, while DKG added the complete 128-graph allow-list and turned a ~3 KB
1219
- // query into the stable 24,051-byte planner stall from issue #1989.
1220
- //
1221
- // If the VALUES shape is dynamic/unsupported or contains an out-of-scope
1222
- // IRI, retain the existing intersection rewrite. This preserves the
1223
- // fail-closed behavior without broadening access.
1224
- return !callerGraphValuesAreAuthorized(sparql, braceStart, variable, allowed);
1225
- });
1226
- if (variablesNeedingConstraint.length === 0)
1227
- return sparql;
1228
- const values = allowedGraphs
1229
- .map((g) => `<${assertSafeIri(g)}>`)
1230
- .join(' ');
1231
- const constraints = variablesNeedingConstraint
1232
- .map((variable) => `VALUES ${variable} { ${values} }`)
1233
- .join(' ');
1234
- return `${sparql.slice(0, braceStart + 1)} ${constraints} ${sparql.slice(braceStart + 1)}`;
1235
- }
1236
- function assertNoTopLevelDefaultGraphPatternsWithGraphVariables(sparql, braceStart) {
1237
- if (!hasTopLevelDefaultGraphPattern(sparql, braceStart))
1238
- return;
1239
- throw new ScopedQueryViolationError('GRAPH variables cannot be mixed with default-graph triple patterns on scoped local queries');
1240
- }
1241
- function hasTopLevelDefaultGraphPattern(sparql, braceStart) {
1242
- const braceEnd = findMatchingCloseBrace(sparql, braceStart);
1243
- if (braceEnd === -1)
1244
- return true;
1245
- const prefixes = collectPrefixDeclarations(sparql);
1246
- let depth = 0;
1247
- let i = braceStart + 1;
1248
- while (i < braceEnd) {
1249
- const ch = sparql[i];
1250
- if (ch === '#') {
1251
- while (i < braceEnd && sparql[i] !== '\n')
1252
- i++;
1253
- continue;
1254
- }
1255
- if (ch === '"' || ch === "'") {
1256
- i = skipSparqlStringLiteral(sparql, i);
1257
- continue;
1258
- }
1259
- if (ch === '{') {
1260
- depth++;
1261
- i++;
1262
- continue;
1263
- }
1264
- if (ch === '}') {
1265
- depth = Math.max(0, depth - 1);
1266
- i++;
1267
- continue;
1268
- }
1269
- if (depth !== 0 || /\s/.test(ch) || ch === '.' || ch === ';' || ch === ',') {
1270
- i++;
1271
- continue;
1272
- }
1273
- if (ch === '<')
1274
- return !!skipSparqlIriRef(sparql, i);
1275
- if (ch === '?' || ch === '$' || ch === '[')
1276
- return true;
1277
- if (isKeywordStart(sparql, i)) {
1278
- let j = i + 1;
1279
- while (j < braceEnd && isWordContinuation(sparql[j]))
1280
- j++;
1281
- if (isSparqlKeyword(sparql, i, j, 'GRAPH')) {
1282
- const operandStart = skipSparqlSpaceAndLineComments(sparql, j);
1283
- const target = readGraphTarget(sparql, operandStart, prefixes);
1284
- i = target && target.end <= braceEnd ? target.end : j;
1285
- continue;
1286
- }
1287
- if (isSparqlKeyword(sparql, i, j, 'FILTER') || isSparqlKeyword(sparql, i, j, 'BIND')) {
1288
- const exprStart = skipSparqlSpaceAndLineComments(sparql, j);
1289
- if (sparql[exprStart] === '(') {
1290
- const exprEnd = skipBalancedParentheses(sparql, exprStart, braceEnd);
1291
- i = exprEnd ?? j;
1292
- continue;
1293
- }
1294
- }
1295
- if (isSparqlKeyword(sparql, i, j, 'VALUES')) {
1296
- i = skipValuesClause(sparql, j, braceEnd);
1297
- continue;
1298
- }
1299
- if (isSparqlKeyword(sparql, i, j, 'OPTIONAL') ||
1300
- isSparqlKeyword(sparql, i, j, 'MINUS') ||
1301
- isSparqlKeyword(sparql, i, j, 'SERVICE') ||
1302
- isSparqlKeyword(sparql, i, j, 'UNION') ||
1303
- isSparqlKeyword(sparql, i, j, 'SELECT')) {
1304
- i = j;
1305
- continue;
1306
- }
1307
- return true;
1308
- }
1309
- i++;
1310
- }
1311
- return false;
1312
- }
1313
- function assertGraphVariablesAreTopLevel(sparql, braceStart) {
1314
- const braceEnd = findMatchingCloseBrace(sparql, braceStart);
1315
- if (braceEnd === -1) {
1316
- throw new ScopedQueryViolationError('GRAPH variables cannot be constrained because the WHERE block could not be located');
1317
- }
1318
- let depth = 0;
1319
- let i = braceStart + 1;
1320
- while (i < braceEnd) {
1321
- const ch = sparql[i];
1322
- if (ch === '#') {
1323
- while (i < braceEnd && sparql[i] !== '\n')
1324
- i++;
1325
- continue;
1326
- }
1327
- if (ch === '"' || ch === "'") {
1328
- i = skipSparqlStringLiteral(sparql, i);
1329
- continue;
1330
- }
1331
- if (ch === '<') {
1332
- const iriEnd = skipSparqlIriRef(sparql, i);
1333
- i = iriEnd && iriEnd <= braceEnd ? iriEnd : i + 1;
1334
- continue;
1335
- }
1336
- if (ch === '{') {
1337
- depth++;
1338
- i++;
1339
- continue;
1340
- }
1341
- if (ch === '}') {
1342
- depth = Math.max(0, depth - 1);
1343
- i++;
1344
- continue;
1345
- }
1346
- if (isKeywordStart(sparql, i)) {
1347
- let j = i + 1;
1348
- while (j < braceEnd && isWordContinuation(sparql[j]))
1349
- j++;
1350
- if (isSparqlKeyword(sparql, i, j, 'GRAPH')) {
1351
- const operandStart = skipSparqlSpaceAndLineComments(sparql, j);
1352
- if (operandStart < braceEnd && readSparqlVariable(sparql, operandStart) && depth !== 0) {
1353
- throw new ScopedQueryViolationError('GRAPH variables must appear at the top level of scoped local queries');
1354
- }
1355
- }
1356
- i = j;
1357
- continue;
1358
- }
1359
- i++;
1360
- }
1361
- }
1362
- function hasNestedSelectWithGraphVariable(sparql) {
1363
- const n = sparql.length;
1364
- let i = 0;
1365
- let braceDepth = 0;
1366
- while (i < n) {
1367
- const ch = sparql[i];
1368
- if (ch === '#') {
1369
- while (i < n && sparql[i] !== '\n')
1370
- i++;
1371
- continue;
1372
- }
1373
- if (ch === '"' || ch === "'") {
1374
- i = skipSparqlStringLiteral(sparql, i);
1375
- continue;
1376
- }
1377
- if (ch === '<') {
1378
- const end = skipSparqlIriRef(sparql, i);
1379
- i = end ?? i + 1;
1380
- continue;
1381
- }
1382
- if (ch === '{') {
1383
- braceDepth++;
1384
- i++;
1385
- continue;
1386
- }
1387
- if (ch === '}') {
1388
- braceDepth = Math.max(0, braceDepth - 1);
1389
- i++;
1390
- continue;
1391
- }
1392
- if (isKeywordStart(sparql, i)) {
1393
- let j = i + 1;
1394
- while (j < n && isWordContinuation(sparql[j]))
1395
- j++;
1396
- const word = sparql.slice(i, j);
1397
- if (isSparqlKeyword(sparql, i, j, 'SELECT') && braceDepth > 0) {
1398
- const end = findNestedSelectEnd(sparql, j, braceDepth);
1399
- if (rangeContainsGraphVariable(sparql, j, end === -1 ? n : end)) {
1400
- return true;
1401
- }
1402
- i = end === -1 ? j : end + 1;
1403
- continue;
1404
- }
1405
- i = j;
1406
- continue;
1407
- }
1408
- i++;
1409
- }
1410
- return false;
1411
- }
1412
- function findNestedSelectEnd(sparql, start, startingDepth) {
1413
- const n = sparql.length;
1414
- let depth = startingDepth;
1415
- let i = start;
1416
- while (i < n) {
1417
- const ch = sparql[i];
1418
- if (ch === '#') {
1419
- while (i < n && sparql[i] !== '\n')
1420
- i++;
1421
- continue;
1422
- }
1423
- if (ch === '"' || ch === "'") {
1424
- i = skipSparqlStringLiteral(sparql, i);
1425
- continue;
1426
- }
1427
- if (ch === '<') {
1428
- const end = skipSparqlIriRef(sparql, i);
1429
- i = end ?? i + 1;
1430
- continue;
1431
- }
1432
- if (ch === '{') {
1433
- depth++;
1434
- i++;
1435
- continue;
1436
- }
1437
- if (ch === '}') {
1438
- depth--;
1439
- if (depth < startingDepth)
1440
- return i;
1441
- if (depth < 0)
1442
- return -1;
1443
- i++;
1444
- continue;
1445
- }
1446
- i++;
1447
- }
1448
- return -1;
1449
- }
1450
- function rangeContainsGraphVariable(sparql, start, end) {
1451
- const n = Math.min(sparql.length, end);
1452
- let i = start;
1453
- while (i < n) {
1454
- const ch = sparql[i];
1455
- if (ch === '#') {
1456
- while (i < n && sparql[i] !== '\n')
1457
- i++;
1458
- continue;
1459
- }
1460
- if (ch === '"' || ch === "'") {
1461
- i = skipSparqlStringLiteral(sparql, i);
1462
- continue;
1463
- }
1464
- if (ch === '<') {
1465
- const iriEnd = skipSparqlIriRef(sparql, i);
1466
- i = iriEnd && iriEnd <= n ? iriEnd : i + 1;
1467
- continue;
1468
- }
1469
- if (isKeywordStart(sparql, i)) {
1470
- let j = i + 1;
1471
- while (j < n && isWordContinuation(sparql[j]))
1472
- j++;
1473
- const word = sparql.slice(i, j);
1474
- if (isSparqlKeyword(sparql, i, j, 'GRAPH')) {
1475
- const operandStart = skipSparqlSpaceAndLineComments(sparql, j);
1476
- if (operandStart < n && readSparqlVariable(sparql, operandStart)) {
1477
- return true;
1478
- }
1479
- }
1480
- i = j;
1481
- continue;
1482
- }
1483
- i++;
1484
- }
1485
- return false;
1486
- }
1487
- function collectExplicitGraphIris(sparql) {
1488
- const iris = [];
1489
- const prefixes = collectPrefixDeclarations(sparql);
1490
- const n = sparql.length;
1491
- let i = 0;
1492
- while (i < n) {
1493
- const ch = sparql[i];
1494
- if (ch === '#') {
1495
- while (i < n && sparql[i] !== '\n')
1496
- i++;
1497
- continue;
1498
- }
1499
- if (ch === '"' || ch === "'") {
1500
- i = skipSparqlStringLiteral(sparql, i);
1501
- continue;
1502
- }
1503
- if (ch === '<') {
1504
- const end = skipSparqlIriRef(sparql, i);
1505
- i = end ?? i + 1;
1506
- continue;
1507
- }
1508
- if (isKeywordStart(sparql, i)) {
1509
- let j = i + 1;
1510
- while (j < n && isWordContinuation(sparql[j]))
1511
- j++;
1512
- if (isSparqlKeyword(sparql, i, j, 'GRAPH')) {
1513
- const operandStart = skipSparqlSpaceAndLineComments(sparql, j);
1514
- const target = readGraphTarget(sparql, operandStart, prefixes);
1515
- if (!target) {
1516
- throw new ScopedQueryViolationError('GRAPH target must be a variable, explicit IRI, or resolvable prefixed name on scoped queries');
1517
- }
1518
- if (target.kind === 'iri' && target.iri)
1519
- iris.push(target.iri);
1520
- i = target.end;
1521
- continue;
1522
- }
1523
- i = j;
1524
- continue;
1525
- }
1526
- i++;
1527
- }
1528
- return iris;
1529
- }
1530
- function hasGraphClause(sparql) {
1531
- const n = sparql.length;
1532
- let i = 0;
1533
- while (i < n) {
1534
- const ch = sparql[i];
1535
- if (ch === '#') {
1536
- while (i < n && sparql[i] !== '\n')
1537
- i++;
1538
- continue;
1539
- }
1540
- if (ch === '"' || ch === "'") {
1541
- i = skipSparqlStringLiteral(sparql, i);
1542
- continue;
1543
- }
1544
- if (ch === '<') {
1545
- const end = skipSparqlIriRef(sparql, i);
1546
- i = end ?? i + 1;
1547
- continue;
1548
- }
1549
- if (isKeywordStart(sparql, i)) {
1550
- let j = i + 1;
1551
- while (j < n && isWordContinuation(sparql[j]))
1552
- j++;
1553
- if (isSparqlKeyword(sparql, i, j, 'GRAPH')) {
1554
- return true;
1555
- }
1556
- i = j;
1557
- continue;
1558
- }
1559
- i++;
1560
- }
1561
- return false;
1562
- }
1563
- function collectGraphVariables(sparql) {
1564
- const variables = [];
1565
- const seen = new Set();
1566
- const n = sparql.length;
1567
- let i = 0;
1568
- while (i < n) {
1569
- const ch = sparql[i];
1570
- if (ch === '#') {
1571
- while (i < n && sparql[i] !== '\n')
1572
- i++;
1573
- continue;
1574
- }
1575
- if (ch === '"' || ch === "'") {
1576
- i = skipSparqlStringLiteral(sparql, i);
1577
- continue;
1578
- }
1579
- if (ch === '<') {
1580
- const end = skipSparqlIriRef(sparql, i);
1581
- i = end ?? i + 1;
1582
- continue;
1583
- }
1584
- if (isKeywordStart(sparql, i)) {
1585
- let j = i + 1;
1586
- while (j < n && isWordContinuation(sparql[j]))
1587
- j++;
1588
- const word = sparql.slice(i, j);
1589
- if (isSparqlKeyword(sparql, i, j, 'GRAPH')) {
1590
- const operandStart = skipSparqlSpaceAndLineComments(sparql, j);
1591
- const variable = readSparqlVariable(sparql, operandStart);
1592
- if (variable && !seen.has(variable)) {
1593
- seen.add(variable);
1594
- variables.push(variable);
1595
- }
1596
- i = operandStart + (variable?.length ?? 0);
1597
- continue;
1598
- }
1599
- i = j;
1600
- continue;
1601
- }
1602
- i++;
1603
- }
1604
- return variables;
1605
- }
1606
- function skipBalancedParentheses(sparql, start, limit = sparql.length) {
1607
- if (sparql[start] !== '(')
1608
- return null;
1609
- let depth = 1;
1610
- let i = start + 1;
1611
- while (i < limit) {
1612
- const ch = sparql[i];
1613
- if (ch === '#') {
1614
- while (i < limit && sparql[i] !== '\n')
1615
- i++;
1616
- continue;
1617
- }
1618
- if (ch === '"' || ch === "'") {
1619
- i = skipSparqlStringLiteral(sparql, i);
1620
- continue;
1621
- }
1622
- if (ch === '<') {
1623
- const iriEnd = skipSparqlIriRef(sparql, i);
1624
- i = iriEnd && iriEnd <= limit ? iriEnd : i + 1;
1625
- continue;
1626
- }
1627
- if (ch === '(')
1628
- depth++;
1629
- else if (ch === ')') {
1630
- depth--;
1631
- if (depth === 0)
1632
- return i + 1;
1633
- }
1634
- i++;
1635
- }
1636
- return null;
1637
- }
1638
- function skipValuesClause(sparql, start, limit) {
1639
- let i = start;
1640
- while (i < limit) {
1641
- const ch = sparql[i];
1642
- if (ch === '#') {
1643
- while (i < limit && sparql[i] !== '\n')
1644
- i++;
1645
- continue;
1646
- }
1647
- if (ch === '"' || ch === "'") {
1648
- i = skipSparqlStringLiteral(sparql, i);
1649
- continue;
1650
- }
1651
- if (ch === '{') {
1652
- const end = findMatchingCloseBrace(sparql, i);
1653
- return end === -1 || end > limit ? i : end + 1;
1654
- }
1655
- if (ch === '.')
1656
- return i + 1;
1657
- i++;
1658
- }
1659
- return i;
1660
- }
1661
- /**
1662
- * Skip past a SPARQL string literal starting at `src[i]`, returning the
1663
- * index immediately AFTER the closing quote.
1664
- *
1665
- * Recognises **all four** SPARQL 1.1 literal forms:
1666
- *
1667
- * - `"…"` single-line, double-quoted (escape: `\\`, `\"`, `\n`, …)
1668
- * - `'…'` single-line, single-quoted (same escape grammar)
1669
- * - `"""…"""` long-form, double-quoted (may span newlines, contains
1670
- * raw `"`, `'`, `{`, `}`, `#`, `.` without escaping)
1671
- * - `'''…'''` long-form, single-quoted (same as above)
1672
- *
1673
- * **Caller contract:** `src[i]` MUST be `"` or `'`; otherwise the function
1674
- * returns `i` (no advance). The cursor returned points to the first byte
1675
- * AFTER the literal, ready for the caller to resume its own scan.
1676
- *
1677
- * If a literal is unterminated (truncated input) the function consumes
1678
- * the remainder of the string and returns `src.length`. Callers treat
1679
- * unterminated literals as "the rest of the input is opaque payload",
1680
- * which is the safe choice for structural scans (brace balancing,
1681
- * keyword detection): we do NOT want a stray `{` near the end of a
1682
- * truncated query body to confuse the surrounding scanner.
1683
- *
1684
- * dkg-query-engine.ts:848). The
1685
- * previous helpers (`stripSparqlLineComments`, `scrubStringsAndComments`,
1686
- * `findMatchingCloseBrace`, `findWhereBraceStart`, and
1687
- * `splitTopLevelTripleStatements`) all had their own copy of the
1688
- * single-line literal scanner and NONE recognised triple-quoted
1689
- * literals, so a long-form payload like
1690
- *
1691
- * SELECT ?t WHERE { ?s <p> """contains a {brace} and a #comment""" }
1692
- *
1693
- * leaked `{`, `}`, `#`, `.`, etc. through the structural scrubber and
1694
- * the `minTrust` rewriter (and the SPARQL form classifier, and the
1695
- * triple terminator splitter) misclassified payload as syntax. The
1696
- * downstream effect was the same fail-closed empty result the
1697
- * scrubbing was supposed to prevent. Centralising the lex here means
1698
- * every helper that walks SPARQL source learns triple-quoted handling
1699
- * in one place.
1700
- */
1701
- export function skipSparqlStringLiteral(src, i) {
1702
- return scanSparqlStringLiteral(src, i);
1703
- }
1704
- /**
1705
- * Token-aware locator for the explicit `WHERE` keyword at the
1706
- * top-level of a SPARQL query. Mirrors the lex rules used by
1707
- * {@link findMatchingCloseBrace} / the fallback path in
1708
- * {@link findWhereBraceStart}: skips line comments (`# ... \n`),
1709
- * single/double/triple-quoted string literals (via
1710
- * {@link skipSparqlStringLiteral}), and IRIREFs (`<...>`) so the
1711
- * `WHERE` substring can NOT be sourced from inside any of those
1712
- * payload contexts. The `<` token is disambiguated as IRI-start
1713
- * vs less-than via the same next-byte allow-list as
1714
- * {@link findWhereBraceStart}'s fallback.
1715
- *
1716
- * Returns the index of the `W` of the `WHERE` keyword, or `-1` if
1717
- * none is found at top level. Case-insensitive on the keyword
1718
- * itself, but the surrounding word boundary is enforced (so
1719
- * identifiers like `WHEREVER` / `aWHERE` do NOT match).
1720
- */
1721
- function findExplicitWhereTokenIdx(sparql) {
1722
- const n = sparql.length;
1723
- const isWordStart = (c) => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c === '_';
1724
- const isWordCont = (c) => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
1725
- (c >= '0' && c <= '9') || c === '_';
1726
- const isIriStartFirstByte = (c) => {
1727
- if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'))
1728
- return true;
1729
- return c === '#' || c === '_' || c === '/' || c === '.';
1730
- };
1731
- const isIriStart = (idx) => {
1732
- const next = sparql[idx + 1];
1733
- if (next === undefined)
1734
- return false;
1735
- if (!isIriStartFirstByte(next))
1736
- return false;
1737
- for (let j = idx + 1; j < n; j++) {
1738
- const c = sparql[j];
1739
- if (c === '>')
1740
- return true;
1741
- if (c === '<' || c === '"' || c === '{' || c === '}' ||
1742
- c === '|' || c === '\\' || c === '^' || c === '`' ||
1743
- /\s/.test(c))
1744
- return false;
1745
- }
1746
- return false;
1747
- };
1748
- let i = 0;
1749
- let braceDepth = 0;
1750
- while (i < n) {
1751
- const ch = sparql[i];
1752
- if (ch === '#') {
1753
- while (i < n && sparql[i] !== '\n')
1754
- i++;
1755
- continue;
1756
- }
1757
- if (ch === '"' || ch === "'") {
1758
- i = skipSparqlStringLiteral(sparql, i);
1759
- continue;
1760
- }
1761
- if (ch === '<') {
1762
- if (isIriStart(i)) {
1763
- const end = sparql.indexOf('>', i + 1);
1764
- if (end === -1)
1765
- return -1;
1766
- i = end + 1;
1767
- continue;
1768
- }
1769
- i++;
1770
- continue;
1771
- }
1772
- if (ch === '{') {
1773
- braceDepth++;
1774
- i++;
1775
- continue;
1776
- }
1777
- if (ch === '}') {
1778
- braceDepth = Math.max(0, braceDepth - 1);
1779
- i++;
1780
- continue;
1781
- }
1782
- if (isWordStart(ch)) {
1783
- // Word boundary check: previous char (if any) must NOT be a
1784
- // word-continuation byte. The outer lexer already skipped
1785
- // comments/strings/IRIs, so a non-word predecessor means we're
1786
- // at a real keyword start.
1787
- const prev = i > 0 ? sparql[i - 1] : '';
1788
- if (prev && isWordCont(prev)) {
1789
- // Mid-identifier — skip the rest of the word.
1790
- let j = i + 1;
1791
- while (j < n && isWordCont(sparql[j]))
1792
- j++;
1793
- i = j;
1794
- continue;
1795
- }
1796
- let j = i + 1;
1797
- while (j < n && isWordCont(sparql[j]))
1798
- j++;
1799
- const word = sparql.substring(i, j);
1800
- if (braceDepth === 0 && word.length === 5 && word.toUpperCase() === 'WHERE') {
1801
- return i;
1802
- }
1803
- i = j;
1804
- continue;
1805
- }
1806
- i++;
1807
- }
1808
- return -1;
1809
- }
1810
- /**
1811
- * Find the next significant `{` after a given index, skipping
1812
- * whitespace AND line comments (`# … \n`) but NOT string literals
1813
- * — SPARQL grammar does not allow a string literal between the
1814
- * `WHERE` keyword and its opening `{`, so encountering one means
1815
- * the input is malformed and we should bail (return `-1`).
1816
- */
1817
- function nextSignificantBraceAfter(sparql, startIdx) {
1818
- const n = sparql.length;
1819
- let i = startIdx;
1820
- while (i < n) {
1821
- const ch = sparql[i];
1822
- if (ch === '#') {
1823
- while (i < n && sparql[i] !== '\n')
1824
- i++;
1825
- continue;
1826
- }
1827
- if (/\s/.test(ch)) {
1828
- i++;
1829
- continue;
1830
- }
1831
- if (ch === '{')
1832
- return i;
1833
- return -1;
1834
- }
1835
- return -1;
1836
- }
1837
- /**
1838
- * Locate the opening `{` of the WHERE clause in a SPARQL query.
1839
- *
1840
- * SPARQL 1.1 (§16) allows the `WHERE` keyword to be omitted from
1841
- * `SELECT`, `DESCRIBE`, and `ASK` queries, and from the second
1842
- * `GroupGraphPattern` of a `CONSTRUCT`. The legacy callers (`wrapWithGraph`,
1843
- * `wrapWithGraphUnion`, `injectMinTrustFilter`) all matched only
1844
- * `WHERE\s*\{`, so any of those legitimate shorthand forms left the
1845
- * query untouched (no GRAPH wrapping, no trust filter injection) and —
1846
- * on a `verifiable-memory` view whose data lives in a named sub-graph —
1847
- * silently returned `[]` instead of executing against the right graph.
1848
- *
1849
- * Strategy:
1850
- * 1. Prefer the explicit `WHERE { ... }` form.
1851
- * 2. Otherwise, walk top-level braces (skipping IRIs / quoted
1852
- * strings / comments) and use the LAST top-level `{...}`. This
1853
- * is correct for every form:
1854
- * - `SELECT ?x { ... }` (1 top-level brace)
1855
- * - `ASK { ... }` (1)
1856
- * - `DESCRIBE ?x { ... }` (1)
1857
- * - `CONSTRUCT { tmpl } { where }`(2 — last is the WHERE)
1858
- * `CONSTRUCT WHERE { ... }` already matches the primary path.
1859
- *
1860
- * Returns `null` when no top-level `{...}` block is balanced.
1861
- */
1862
- function findWhereBraceStart(sparql) {
1863
- // The earlier fast path used a raw regex `/\bWHERE\s*\{/i` which
1864
- // matches ANY `WHERE` followed by `{` — including ones embedded inside
1865
- // string literals or comments. Adversarial / obfuscated input
1866
- // like
1867
- // SELECT ("WHERE {" AS ?x) WHERE { ... }
1868
- // would have the regex hit the literal substring inside the
1869
- // SELECT projection, then `sparql.indexOf('{', whereIdx)` would
1870
- // grab the brace just past the literal — and every later
1871
- // injection (`wrapWithGraph` / `injectMinTrustFilter`) would
1872
- // rewrite the wrong block, in some cases producing an invalid
1873
- // query and in others silently filtering against a string-literal
1874
- // expression rather than the actual WHERE clause.
1875
- //
1876
- // Fix: locate the explicit `WHERE` token using the SAME token-
1877
- // aware scanner the fallback already uses (skips line comments,
1878
- // single/double/triple-quoted string literals, and IRIREFs;
1879
- // disambiguates `<` as IRI-start vs less-than via the next-byte
1880
- // allow-list below). Then advance past inter-keyword whitespace
1881
- // (and any line comments) before reading the `{`.
1882
- const whereTokenIdx = findExplicitWhereTokenIdx(sparql);
1883
- if (whereTokenIdx !== -1) {
1884
- const idx = nextSignificantBraceAfter(sparql, whereTokenIdx + 'WHERE'.length);
1885
- return idx;
1886
- }
1887
- // Fallback: scan for top-level `{` while honouring SPARQL token
1888
- // boundaries — IRIs (`<...>`), quoted literals, and `#` comments
1889
- // can all contain stray `{` chars that the regex would
1890
- // misinterpret as block openers.
1891
- //
1892
- // dkg-query-engine.ts:559). The classifier rejects obvious
1893
- // comparison shapes after `<` and falls back to a forward scan
1894
- // that confirms a balanced IRIREF body. The r30 cut only rejected
1895
- // `=`, `<`, and whitespace — a pure forward scan from `<` in
1896
- // compact comparison syntax like
1897
- // `FILTER(?n<10&&?m>5)`
1898
- // walks `1`, `0`, `&`, `&`, `?`, `m` (none of which are
1899
- // IRIREF-forbidden per the SPARQL grammar
1900
- // `[^<>"{}|^`\]-[#x00-#x20]`) and lands on `>`, mis-classifying
1901
- // the entire `<10&&?m>` as an IRI. The forward scan therefore
1902
- // CANNOT be trusted alone for compact `<` operators that operate
1903
- // on numerics / variables / sub-expressions whose body bytes are
1904
- // all IRIREF-legal.
1905
- //
1906
- // r30+ resolution: combine an EXPLICIT next-byte allow-list of
1907
- // characters that can validly start a real-world SPARQL IRIREF
1908
- // (ALPHA for absolute IRIs `http:` / `urn:` / `did:` / `file:` /
1909
- // `_blank-node:`, `#` for fragment-only relatives, `_` for the
1910
- // legacy blank-node-as-IRI shape, `/` for path-only relatives,
1911
- // and `.` for path-relative IRIs) with the existing
1912
- // forbidden-byte forward scan. Anything else after `<` is treated
1913
- // as a comparison and we advance by ONE byte. This bails fast on
1914
- // every `<digit`, `<?var`, `<$var`, `<(...)`, `<"lit"`, `<-1`,
1915
- // `<+1`, `<&`, `<|`, `<!`, `<*`, `<=`, `<<` shape — i.e. the
1916
- // full set of SPARQL operator contexts in which `<` is overloaded
1917
- // as less-than.
1918
- //
1919
- // Note: this is INTENTIONALLY stricter than the SPARQL grammar
1920
- // (which technically allows `<10>` as an IRIREF). Real-world
1921
- // SPARQL queries don't write bare-digit IRIs; falling out of the
1922
- // IRI branch here just means we treat `<` as a comparison and
1923
- // advance one byte, which is the safe behaviour for the brace
1924
- // scan we actually care about.
1925
- const isIriStartFirstByte = (c) => {
1926
- // ASCII letter? (covers every absolute IRI scheme — `http:`,
1927
- // `urn:`, `did:`, `file:`, `mailto:`, `tag:`, `data:`, …).
1928
- if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'))
1929
- return true;
1930
- // `#fragment` (SPARQL allows fragment-only relative IRIREFs
1931
- // when the base IRI is set by the query environment), `_blah`
1932
- // (legacy blank-node-as-IRI), `/path` (path-only relative),
1933
- // `.something` (path-relative). Everything else is a comparison
1934
- // operator context.
1935
- return c === '#' || c === '_' || c === '/' || c === '.';
1936
- };
1937
- const isIriStart = (idx) => {
1938
- const next = sparql[idx + 1];
1939
- if (next === undefined)
1940
- return false;
1941
- if (!isIriStartFirstByte(next))
1942
- return false;
1943
- for (let j = idx + 1; j < n; j++) {
1944
- const c = sparql[j];
1945
- if (c === '>')
1946
- return true;
1947
- // Any IRIREF-forbidden character before `>` proves this `<`
1948
- // is a comparison, not the start of an IRI.
1949
- if (c === '<' ||
1950
- c === '"' ||
1951
- c === '{' ||
1952
- c === '}' ||
1953
- c === '|' ||
1954
- c === '\\' ||
1955
- c === '^' ||
1956
- c === '`' ||
1957
- /\s/.test(c)) {
1958
- return false;
1959
- }
1960
- }
1961
- return false;
1962
- };
1963
- const n = sparql.length;
1964
- const opens = [];
1965
- let depth = 0;
1966
- let i = 0;
1967
- while (i < n) {
1968
- const ch = sparql[i];
1969
- if (ch === '#') {
1970
- while (i < n && sparql[i] !== '\n')
1971
- i++;
1972
- continue;
1973
- }
1974
- if (ch === '<') {
1975
- if (isIriStart(i)) {
1976
- const end = sparql.indexOf('>', i + 1);
1977
- if (end === -1)
1978
- return -1;
1979
- i = end + 1;
1980
- continue;
1981
- }
1982
- // Comparison operator — advance one byte and keep scanning.
1983
- i++;
1984
- continue;
1985
- }
1986
- if (ch === '"' || ch === "'") {
1987
- // dkg-query-engine.ts:848).
1988
- // Centralised triple-quoted-aware skip — see skipSparqlStringLiteral.
1989
- i = skipSparqlStringLiteral(sparql, i);
1990
- continue;
1991
- }
1992
- if (ch === '{') {
1993
- if (depth === 0)
1994
- opens.push(i);
1995
- depth++;
1996
- }
1997
- else if (ch === '}') {
1998
- depth--;
1999
- if (depth < 0)
2000
- return -1;
2001
- }
2002
- i++;
2003
- }
2004
- if (depth !== 0 || opens.length === 0)
2005
- return -1;
2006
- return opens[opens.length - 1];
2007
- }
2008
- /**
2009
- * Wraps a SELECT query to scope it to a named graph.
2010
- * If the query already uses GRAPH patterns, returns it unchanged.
2011
- */
2012
- function wrapWithGraph(sparql, graphUri) {
2013
- if (hasGraphClause(sparql))
2014
- return sparql;
2015
- const braceStart = findWhereBraceStart(sparql);
2016
- if (braceStart === -1)
2017
- return sparql;
2018
- // — dkg-query-engine.ts:939). Use the
2019
- // literal/comment/IRI-aware helper so a `{` or `}` inside a SPARQL
2020
- // string literal, line comment, or IRI does NOT confuse the depth
2021
- // counter and we stop wrapping queries with literal-heavy bodies.
2022
- const braceEnd = findMatchingCloseBrace(sparql, braceStart);
2023
- if (braceEnd === -1)
2024
- return sparql;
2025
- const before = sparql.slice(0, braceStart + 1);
2026
- const inner = sparql.slice(braceStart + 1, braceEnd);
2027
- const after = sparql.slice(braceEnd);
2028
- return `${before} GRAPH <${graphUri}> { ${inner} } ${after}`;
2029
- }
2030
- /**
2031
- * Wrap a query so it runs over a union of named graphs in a single execution,
2032
- * preserving LIMIT/ORDER BY/DISTINCT/aggregate semantics.
2033
- *
2034
- * the previous revision injected
2035
- * `VALUES ?_viewGraph { <g1> <g2> … } GRAPH ?_viewGraph { inner }` directly
2036
- * into the caller's WHERE block. Two failure modes:
2037
- *
2038
- * 1. Scope leak — `SELECT *` (or any projection that includes the graph
2039
- * variable) over a multi-graph view emitted an extra `_viewGraph`
2040
- * column, so downstream consumers saw a mystery binding they didn't
2041
- * ask for.
2042
- * 2. Name collision — a user query that legitimately binds
2043
- * `?_viewGraph` (rare but valid) would silently intersect with the
2044
- * helper's VALUES list and clamp to the helper's graph URIs.
2045
- *
2046
- * The fix is to use an explicit UNION over each graph instead of a
2047
- * single GRAPH ?var binding. That keeps the inner block's variables
2048
- * (and only those) in scope — no helper var is introduced at all, so
2049
- * neither SELECT * leakage nor variable-name collisions can happen.
2050
- * Single-graph views skip the UNION wrapper entirely and use a plain
2051
- * `GRAPH <uri>` block.
2052
- *
2053
- * Returns `null` when the inner WHERE body contains a UNION — the
2054
- * UNION-of-GRAPHs wrapper would produce a nested UnionNode that
2055
- * crashes Blazegraph, and a VALUES+GRAPH fallback leaks a helper
2056
- * variable into the caller's scope. The caller should fall back to
2057
- * per-graph execution.
2058
- */
2059
- function wrapWithGraphUnion(sparql, graphUris) {
2060
- if (hasGraphClause(sparql))
2061
- return sparql;
2062
- if (graphUris.length === 0)
2063
- return sparql;
2064
- const braceStart = findWhereBraceStart(sparql);
2065
- if (braceStart === -1)
2066
- return sparql;
2067
- // — dkg-query-engine.ts:939). See
2068
- // `findMatchingCloseBrace` and the `wrapWithGraph` cousin above.
2069
- const braceEnd = findMatchingCloseBrace(sparql, braceStart);
2070
- if (braceEnd === -1)
2071
- return sparql;
2072
- const before = sparql.slice(0, braceStart + 1);
2073
- const inner = sparql.slice(braceStart + 1, braceEnd);
2074
- const after = sparql.slice(braceEnd);
2075
- if (graphUris.length === 1) {
2076
- return `${before} GRAPH <${graphUris[0]}> { ${inner} } ${after}`;
2077
- }
2078
- // Blazegraph crashes with "Illegal child type for union: UnionNode"
2079
- // when a UNION appears inside a GRAPH block that is itself a branch
2080
- // of an outer UNION. We cannot use VALUES+GRAPH either because the
2081
- // helper variable leaks into caller scope (SELECT *, name collisions).
2082
- // Signal the caller to fall back to per-graph execution.
2083
- const innerHasUnion = /\bUNION\b/i.test(inner);
2084
- if (innerHasUnion) {
2085
- return null;
2086
- }
2087
- const unionBranches = graphUris
2088
- .map((g) => `{ GRAPH <${g}> { ${inner} } }`)
2089
- .join(' UNION ');
2090
- return `${before} ${unionBranches} ${after}`;
2091
- }
2092
- /** The graph variable `wrapWithGraphValues` injects. Double-underscore + a
2093
- * view-specific name so a user query is astronomically unlikely to bind it;
2094
- * a collision is nonetheless detected and declined (never silently clamped). */
2095
- const VIEW_GRAPH_SENTINEL = '?__dkgViewGraph';
2096
- const DEDUP_GRAPH_SENTINEL = '?__dkgDedupGraph';
2097
- const DEDUP_RANK_SENTINEL = '?__dkgDedupRank';
2098
- const DEDUP_PRIOR_GRAPH_SENTINEL = '?__dkgDedupPriorGraph';
2099
- const DEDUP_PRIOR_RANK_SENTINEL = '?__dkgDedupPriorRank';
2100
- function wrapWithProjectedGraphSubselect(sparql, graphUris, helperVariables, buildGraphPattern, acceptsInner = () => true) {
2101
- if (hasGraphClause(sparql))
2102
- return sparql;
2103
- if (graphUris.length === 0)
2104
- return sparql;
2105
- const braceStart = findWhereBraceStart(sparql);
2106
- if (braceStart === -1)
2107
- return null;
2108
- const braceEnd = findMatchingCloseBrace(sparql, braceStart);
2109
- if (braceEnd === -1)
2110
- return null;
2111
- const before = sparql.slice(0, braceStart + 1);
2112
- const inner = sparql.slice(braceStart + 1, braceEnd);
2113
- const after = sparql.slice(braceEnd);
2114
- const graphs = [...new Set(graphUris)];
2115
- if (graphs.length === 1) {
2116
- return `${before} GRAPH <${assertSafeIri(graphs[0])}> { ${inner} } ${after}`;
2117
- }
2118
- if (/\bUNION\b/i.test(inner) || !acceptsInner(inner))
2119
- return null;
2120
- const helperNames = new Set(helperVariables.map((variable) => variable.slice(1)));
2121
- if (collectQueryVariables(sparql).some((variable) => helperNames.has(variable.slice(1)))) {
2122
- return null;
2123
- }
2124
- const innerVars = collectQueryVariables(inner);
2125
- if (innerVars.length === 0)
2126
- return null;
2127
- const graphPattern = buildGraphPattern(inner, graphs);
2128
- return `${before} { SELECT ${innerVars.join(' ')} WHERE { ${graphPattern} } } ${after}`;
2129
- }
2130
- /**
2131
- * Run one graph pattern across an ordered graph set while suppressing only a
2132
- * solution mapping already produced by an earlier graph. The comparison uses
2133
- * every variable bound by the caller's inner pattern, before the caller's
2134
- * projection runs. Thus an identical mirrored triple is emitted once, while
2135
- * distinct triples that both project to the same `?s` still produce two rows.
2136
- *
2137
- * Helper graph/rank variables are hidden inside a sub-SELECT, preserving
2138
- * `SELECT *` and caller DISTINCT semantics. Unsupported/colliding query shapes
2139
- * return null so the existing generic multi-graph fallback remains available.
2140
- */
2141
- function wrapWithDeduplicatedGraphValues(sparql, graphUris) {
2142
- return wrapWithProjectedGraphSubselect(sparql, graphUris, [
2143
- DEDUP_GRAPH_SENTINEL,
2144
- DEDUP_RANK_SENTINEL,
2145
- DEDUP_PRIOR_GRAPH_SENTINEL,
2146
- DEDUP_PRIOR_RANK_SENTINEL,
2147
- ], (inner, graphs) => {
2148
- const rows = graphs
2149
- .map((graph, rank) => `(<${assertSafeIri(graph)}> ${rank})`)
2150
- .join(' ');
2151
- return [
2152
- `VALUES (${DEDUP_GRAPH_SENTINEL} ${DEDUP_RANK_SENTINEL}) { ${rows} }`,
2153
- `GRAPH ${DEDUP_GRAPH_SENTINEL} { ${inner} }`,
2154
- 'FILTER NOT EXISTS {',
2155
- ` VALUES (${DEDUP_PRIOR_GRAPH_SENTINEL} ${DEDUP_PRIOR_RANK_SENTINEL}) { ${rows} }`,
2156
- ` FILTER (${DEDUP_PRIOR_RANK_SENTINEL} < ${DEDUP_RANK_SENTINEL})`,
2157
- ` GRAPH ${DEDUP_PRIOR_GRAPH_SENTINEL} { ${inner} }`,
2158
- '}',
2159
- ].join(' ');
2160
- }, isDedupSafeBasicGraphPattern);
2161
- }
2162
- /**
2163
- * Wrap a query so it runs over a set of named graphs in ONE execution using a
2164
- * single `VALUES ?g { <g1> … } GRAPH ?g { inner }` block, instead of one
2165
- * `{ GRAPH <g> { inner } } UNION …` branch per graph.
2166
- *
2167
- * Why this exists (issue #1596): the per-graph UNION form (`wrapWithGraphUnion`)
2168
- * emits an N-branch UnionNode. At the ~1,424 SWM graphs of a large public
2169
- * context graph that is a ~257 KB query whose union tree makes the
2170
- * oxigraph-server query planner blow up and reset the connection ("fetch
2171
- * failed"). A single VALUES table is one basic graph pattern iterated over the
2172
- * graph list — constant plan depth — so it scales.
2173
- *
2174
- * Semantics are preserved. LIMIT/ORDER BY/DISTINCT/GROUP BY/aggregate all live
2175
- * in the outer query (the `before`/`after` slices) and reduce over the merged
2176
- * solution set exactly as the UNION form did. The one hazard is the injected
2177
- * graph variable leaking into the caller's projection: a bare `SELECT *` would
2178
- * expose `?__dkgViewGraph` as a mystery column, and cross-graph DISTINCT of an
2179
- * identical triple would split on the graph. Both are neutralised by scoping
2180
- * the VALUES+GRAPH block inside a sub-SELECT that projects ONLY the user's own
2181
- * variables (collected from `inner`), so the sentinel never escapes.
2182
- *
2183
- * Returns `null` (caller falls back to `wrapWithGraphUnion` / per-graph
2184
- * execution) when the WHERE block cannot be located, when `inner` carries a
2185
- * top-level UNION (kept on the #789 form-aware fallback so its behaviour and
2186
- * tests are untouched), when the user query already binds the sentinel, or
2187
- * when the WHERE body binds no user variable at all (a var-less body offers
2188
- * nowhere to hide the sentinel from a `SELECT *` projection).
2189
- */
2190
- function wrapWithGraphValues(sparql, graphUris) {
2191
- return wrapWithProjectedGraphSubselect(sparql, graphUris, [VIEW_GRAPH_SENTINEL], (inner, graphs) => {
2192
- const values = graphs.map((graph) => `<${assertSafeIri(graph)}>`).join(' ');
2193
- return `VALUES ${VIEW_GRAPH_SENTINEL} { ${values} } GRAPH ${VIEW_GRAPH_SENTINEL} { ${inner} }`;
2194
- });
2195
- }
2196
- /**
2197
- * The mirror anti-join is valid only for a flat basic graph pattern (plus
2198
- * FILTER expressions). Nested graph patterns can differ by boundness, where a
2199
- * correlated NOT EXISTS compatibility check is not exact mapping equality.
2200
- */
2201
- function isDedupSafeBasicGraphPattern(inner) {
2202
- const forbidden = ['OPTIONAL', 'MINUS', 'SERVICE', 'VALUES', 'BIND', 'SELECT', 'GRAPH', 'EXISTS'];
2203
- let i = 0;
2204
- while (i < inner.length) {
2205
- const ch = inner[i];
2206
- if (ch === '#') {
2207
- while (i < inner.length && inner[i] !== '\n')
2208
- i++;
2209
- continue;
2210
- }
2211
- if (ch === '"' || ch === "'") {
2212
- i = skipSparqlStringLiteral(inner, i);
2213
- continue;
2214
- }
2215
- if (ch === '<') {
2216
- const end = skipSparqlIriRef(inner, i);
2217
- i = end ?? i + 1;
2218
- continue;
2219
- }
2220
- if (ch === '{' || ch === '}')
2221
- return false;
2222
- if (isKeywordStart(inner, i)) {
2223
- let end = i + 1;
2224
- while (end < inner.length && isWordContinuation(inner[end]))
2225
- end++;
2226
- if (forbidden.some((keyword) => isSparqlKeyword(inner, i, end, keyword))) {
2227
- return false;
2228
- }
2229
- i = end;
2230
- continue;
2231
- }
2232
- i++;
2233
- }
2234
- return true;
2235
- }
2236
- /**
2237
- * Collect every distinct SPARQL variable (`?x` / `$x`, in first-seen order,
2238
- * sigil included) in a query fragment, skipping tokens inside line comments,
2239
- * string literals, and IRI refs. Reuses the same literal/comment/IRI-aware
2240
- * primitives as `collectGraphVariables` so a `?`/`$` inside a literal or IRI is
2241
- * never mistaken for a variable.
2242
- */
2243
- function collectQueryVariables(sparql) {
2244
- const variables = [];
2245
- const seen = new Set();
2246
- const n = sparql.length;
2247
- let i = 0;
2248
- while (i < n) {
2249
- const ch = sparql[i];
2250
- if (ch === '#') {
2251
- while (i < n && sparql[i] !== '\n')
2252
- i++;
2253
- continue;
2254
- }
2255
- if (ch === '"' || ch === "'") {
2256
- i = skipSparqlStringLiteral(sparql, i);
2257
- continue;
2258
- }
2259
- if (ch === '<') {
2260
- const end = skipSparqlIriRef(sparql, i);
2261
- i = end ?? i + 1;
2262
- continue;
2263
- }
2264
- if (ch === '?' || ch === '$') {
2265
- const variable = readSparqlVariable(sparql, i);
2266
- if (variable) {
2267
- if (!seen.has(variable)) {
2268
- seen.add(variable);
2269
- variables.push(variable);
2270
- }
2271
- i += variable.length;
2272
- continue;
2273
- }
2274
- }
2275
- i++;
2276
- }
2277
- return variables;
2278
- }
2279
- /**
2280
- * Rewrites a SPARQL query so EVERY subject variable used in its WHERE
2281
- * block also matches `<http://dkg.io/ontology/trustLevel> ?__trustN`
2282
- * with an integer value ≥ `minTrust`. Subjects with no trust metadata
2283
- * are filtered out (the required triple is absent).
2284
- *
2285
- * The rewriter scans the WHERE block for top-level triple patterns
2286
- * and collects every distinct subject variable so multi-subject
2287
- * queries like `?a <p> ?o . ?b <q> ?r` have BOTH `?a` and `?b`
2288
- * trust-filtered.
2289
- *
2290
- * Returns `null` when:
2291
- * - no `WHERE { ... }` block can be located;
2292
- * - braces are unbalanced;
2293
- * - the WHERE contains nested structure (`{`, `GRAPH`, `OPTIONAL`,
2294
- * `UNION`, `MINUS`, `SERVICE`, subselect) we cannot safely rewrite;
2295
- * - the block contains a constant (IRI/literal/blank) subject — we
2296
- * cannot attach a filter to a constant, and silently ignoring the
2297
- * constant row would leak sub-threshold data (L1 fail-closed);
2298
- * - no subject var is found at all.
2299
- * Callers treat `null` as "refuse to run".
2300
- */
2301
- /**
2302
- * Strip SPARQL line comments (`# … EOL`) from a fragment of SPARQL
2303
- * WHERE body while preserving `#` that appears inside an IRI
2304
- * (`<http://…/rdf-ns#type>`) or inside a string literal (`"…#…"`,
2305
- * `'…#…'`). Used by `injectMinTrustFilter` where a full parser would
2306
- * be overkill but a naive line-comment regex mangles `rdf:type` etc.
2307
- *
2308
- * This is intentionally small: we handle the three grammar contexts
2309
- * that can legally contain a bare `#` in SPARQL 1.1 (IRI, quoted
2310
- * literal, line comment) and treat everything else as ordinary code.
2311
- * Triple-quoted `"""…"""` / `'''…'''` are NOT recognised because
2312
- * `injectMinTrustFilter` already bails on any WHERE containing tokens
2313
- * from the multi-line literal grammar (FILTER EXISTS, SELECT, …).
2314
- */
2315
- function stripSparqlLineComments(src) {
2316
- let out = '';
2317
- let i = 0;
2318
- const n = src.length;
2319
- while (i < n) {
2320
- const ch = src[i];
2321
- if (ch === '<') {
2322
- const end = src.indexOf('>', i + 1);
2323
- if (end === -1) {
2324
- out += src.slice(i);
2325
- break;
2326
- }
2327
- out += src.slice(i, end + 1);
2328
- i = end + 1;
2329
- continue;
2330
- }
2331
- if (ch === '"' || ch === "'") {
2332
- // Centralised triple-quoted-aware skip.
2333
- const j = skipSparqlStringLiteral(src, i);
2334
- out += src.slice(i, j);
2335
- i = j;
2336
- continue;
2337
- }
2338
- if (ch === '#') {
2339
- const nl = src.indexOf('\n', i);
2340
- if (nl === -1) {
2341
- break;
2342
- }
2343
- i = nl; // leave the newline so dot-accounting still sees line breaks
2344
- continue;
2345
- }
2346
- out += ch;
2347
- i++;
2348
- }
2349
- return out;
2350
- }
2351
- /**
2352
- * Replace every SPARQL string literal and `# …` comment in `src`
2353
- * with neutral whitespace, preserving overall byte length. IRIs and
2354
- * code tokens are passed through verbatim. The returned string is
2355
- * suitable for STRUCTURAL CHECKS (brace balancing, keyword scans)
2356
- * that must not be confused by user payloads such as
2357
- * `"{json: 1}"` or `# OPTIONAL: ...`.
2358
- *
2359
- * Triple-quoted
2360
- * (`"""…"""` / `'''…'''`) literals are NOT recognised because
2361
- * `injectMinTrustFilter`'s outer pipeline already refuses any WHERE
2362
- * carrying tokens from the multi-line literal grammar (FILTER EXISTS,
2363
- * SELECT inside, etc.).
2364
- */
2365
- function scrubStringsAndComments(src) {
2366
- const n = src.length;
2367
- const buf = new Array(n);
2368
- let i = 0;
2369
- while (i < n) {
2370
- const ch = src[i];
2371
- if (ch === '<') {
2372
- const end = src.indexOf('>', i + 1);
2373
- if (end === -1) {
2374
- for (let k = i; k < n; k++)
2375
- buf[k] = src[k];
2376
- return buf.join('');
2377
- }
2378
- for (let k = i; k <= end; k++)
2379
- buf[k] = src[k];
2380
- i = end + 1;
2381
- continue;
2382
- }
2383
- if (ch === '"' || ch === "'") {
2384
- // Centralised triple-quoted-aware skip.
2385
- const j = skipSparqlStringLiteral(src, i);
2386
- for (let k = i; k < j; k++)
2387
- buf[k] = src[k] === '\n' ? '\n' : ' ';
2388
- i = j;
2389
- continue;
2390
- }
2391
- if (ch === '#') {
2392
- const nl = src.indexOf('\n', i);
2393
- const end = nl === -1 ? n : nl;
2394
- for (let k = i; k < end; k++)
2395
- buf[k] = ' ';
2396
- i = end;
2397
- continue;
2398
- }
2399
- buf[i] = ch;
2400
- i++;
2401
- }
2402
- return buf.join('');
2403
- }
2404
- /**
2405
- * Split a SPARQL WHERE body on **top-level** triple terminators, i.e.
2406
- * dots that live outside quoted literals and outside IRI angle
2407
- * brackets. The earlier `/\.(?=\s|$)/` regex broke on literal dots
2408
- * in messages like `?s <p> "hello. world"`, silently fragmenting
2409
- * the statement so the subject scanner returned garbage and
2410
- * `_minTrust` fail-closed to `[]` for every text/chat query. This
2411
- * tokenizer walks the body character
2412
- * by character, tracks `<…>` and `"…"` / `'…'` scopes (with `\`-escape
2413
- * handling), and only treats `.` as a separator when it sits at depth
2414
- * zero and is followed by whitespace or end-of-input. Comments have
2415
- * already been stripped by {@link stripSparqlLineComments} before we
2416
- * get here, so `#` is treated as an ordinary character.
2417
- *
2418
- * Parentheses and braces would also open top-level scopes in general
2419
- * SPARQL, but `injectMinTrustFilter` refuses to rewrite any WHERE that
2420
- * contains `{`, `}`, `FILTER EXISTS`, subselects, or property paths
2421
- * with grouping (the `/\{|\}/.test(inner)` + token guard above), so
2422
- * this helper only has to handle the three grammar contexts that can
2423
- * legally carry a bare `.` in the shapes we rewrite: IRI, string
2424
- * literal, and top-level statement terminator.
2425
- */
2426
- function splitTopLevelTripleStatements(body) {
2427
- const out = [];
2428
- let start = 0;
2429
- let i = 0;
2430
- const n = body.length;
2431
- while (i < n) {
2432
- const ch = body[i];
2433
- if (ch === '<') {
2434
- const end = body.indexOf('>', i + 1);
2435
- if (end === -1) {
2436
- i = n;
2437
- break;
2438
- }
2439
- i = end + 1;
2440
- continue;
2441
- }
2442
- if (ch === '"' || ch === "'") {
2443
- // Centralised triple-quoted-aware skip.
2444
- i = skipSparqlStringLiteral(body, i);
2445
- continue;
2446
- }
2447
- if (ch === '.') {
2448
- // Terminator only when followed by whitespace OR end-of-input.
2449
- // This keeps decimals and prefixed-name dots (rdf:type.foo —
2450
- // rejected upstream anyway) from accidentally splitting, and
2451
- // matches the original regex semantics on the top-level cases.
2452
- const next = i + 1 < n ? body[i + 1] : '';
2453
- if (next === '' || /\s/.test(next)) {
2454
- const piece = body.slice(start, i).trim();
2455
- if (piece)
2456
- out.push(piece);
2457
- start = i + 1;
2458
- i += 1;
2459
- continue;
2460
- }
2461
- }
2462
- i++;
2463
- }
2464
- const tail = body.slice(start).trim();
2465
- if (tail)
2466
- out.push(tail);
2467
- return out;
2468
- }
2469
- function injectMinTrustFilter(sparql, minTrust) {
2470
- // The
2471
- // pre-fix rewriter only recognised `WHERE\s*\{`, so SPARQL 1.1
2472
- // shorthand forms (`SELECT ?x { … }`, `ASK { … }`,
2473
- // `DESCRIBE ?x { … }`, `CONSTRUCT { tmpl } { where }`) returned
2474
- // `null` and the `minTrust > Endorsed` caller silently fell
2475
- // through to an empty result. `findWhereBraceStart` normalises
2476
- // every shape to the WHERE-clause brace position before we apply
2477
- // the existing depth-counting pass below.
2478
- const braceStart = findWhereBraceStart(sparql);
2479
- if (braceStart === -1)
2480
- return null;
2481
- // — dkg-query-engine.ts:939). The
2482
- // earlier brace-balance loop counted `{`/`}` inside SPARQL string
2483
- // literals (e.g. `FILTER(STR(?t) = "{")`), so a literal-heavy WHERE
2484
- // ended at depth 1 and `injectMinTrustFilter` returned `null` —
2485
- // which the `_minTrust > Endorsed` caller treats as "refuse to run"
2486
- // and silently fails closed. Use the literal/comment/IRI-aware
2487
- // helper so the brace boundaries match what SPARQL actually
2488
- // parses.
2489
- const braceEnd = findMatchingCloseBrace(sparql, braceStart);
2490
- if (braceEnd === -1)
2491
- return null;
2492
- const inner = sparql.slice(braceStart + 1, braceEnd);
2493
- // A
2494
- // leading top-level `VALUES` clause is the canonical SPARQL shape
2495
- // for batched exact-subject lookups:
2496
- //
2497
- // SELECT ?o WHERE {
2498
- // VALUES ?s { <a> <b> <c> }
2499
- // ?s <p> ?o .
2500
- // }
2501
- //
2502
- // the forbidden-tokens regex treated any VALUES as
2503
- // "unsupported" and `_minTrust` fell through to
2504
- // `emptyResultForForm(...)`, which turns into a silent `[]` / `false`
2505
- // even when the bound subjects satisfy the threshold. The contract
2506
- // we need is:
2507
- // (a) bail loudly on complex VALUES we can't reason about
2508
- // (multi-var tuples, multi-line, no closing `}`);
2509
- // (b) for the common single-var VALUES case, peel it off, run
2510
- // the existing subject analysis on the body, and re-emit
2511
- // the VALUES binding at the top of the rewritten WHERE so
2512
- // the trust filter still applies to each bound IRI.
2513
- //
2514
- // Any other location (non-leading, multi-var, parenthesised row
2515
- // syntax `VALUES (?x ?y) { (<a> "b") }`) still bails because the
2516
- // flat scanner cannot safely rewrite them.
2517
- const { valuesClause, bodyAfterValues } = peelLeadingValues(inner);
2518
- const scanTarget = bodyAfterValues ?? inner;
2519
- // — dkg-query-engine.ts:851).
2520
- // Pre-fix the unsupported-nesting guard `/\{|\}/.test(scanTarget)`
2521
- // and the keyword guard below ran on the RAW WHERE body. Any
2522
- // `{`, `}`, or sensitive keyword that happened to appear inside a
2523
- // SPARQL string literal (`"{json: 1}"`, `"OPTIONAL field"`,
2524
- // `"SELECT * FROM x"`) or inside a `# …` line comment caused the
2525
- // rewriter to bail out and the caller fell through to
2526
- // `emptyResultForSparql(...)`. That silently fail-closed every
2527
- // legitimate high-trust query whose payload happened to mention
2528
- // those tokens — text/JSON/log content is the most common case.
2529
- //
2530
- // Scrub literals and comments to neutral spaces BEFORE the
2531
- // structural / keyword checks so they only see real code tokens.
2532
- // IRIs are preserved verbatim because IRIREF grammar already
2533
- // forbids `{`, `}`, `"`, and the keyword tokens we care about.
2534
- const codeView = scrubStringsAndComments(scanTarget);
2535
- if (/[{}]/.test(codeView))
2536
- return null;
2537
- if (/\b(GRAPH|OPTIONAL|UNION|MINUS|SERVICE|VALUES|FILTER\s+EXISTS|FILTER\s+NOT\s+EXISTS|SELECT)\b/i.test(codeView)) {
2538
- return null;
2539
- }
2540
- // Strip SPARQL line comments (`# … \n`) so the dot accounting below
2541
- // doesn't misclassify "# foo ." as a terminating triple — BUT leave
2542
- // `#` fragments inside IRIs (`<…#…>`) and literals (`"…#…"`) alone.
2543
- // The naive `/#[^\n]*/g` regex used here previously mangled the
2544
- // extremely common `rdf:type` shape
2545
- // `<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>` whenever
2546
- // `_minTrust` was set, which fail-closes the entire query to `[]`
2547
- // .
2548
- const innerCodeOnly = stripSparqlLineComments(scanTarget);
2549
- const trimmedInner = innerCodeOnly.trim();
2550
- if (trimmedInner.length === 0)
2551
- return null;
2552
- // Split on the top-level `.` separator to walk each triple pattern.
2553
- // use a
2554
- // quote/IRI-aware tokenizer instead of a naive regex so `?s <p>
2555
- // "hello. world"` isn't fragmented into broken statements that the
2556
- // subject scanner then refuses, fail-closing `_minTrust` to `[]`
2557
- // for every text/chat query. Rejoined dots are preserved for the
2558
- // emitted query by the clause builder below.
2559
- const statements = splitTopLevelTripleStatements(trimmedInner);
2560
- const subjectVars = new Set();
2561
- const subjectIris = new Set();
2562
- const subjectPrefixed = new Set();
2563
- for (const stmt of statements) {
2564
- // top-level `FILTER(...)` / `BIND(... AS ?x)` clauses share the
2565
- // statement-list with triple patterns and have no subject token.
2566
- // Pre-fix the subject regex below didn't match either keyword,
2567
- // returned `null`, and `injectMinTrustFilter()` propagated `null`
2568
- // — collapsing every query like
2569
- // SELECT ?s WHERE { ?s <p> ?o . FILTER(?o > 10) }
2570
- // into an empty result whenever `minTrust > SelfAttested`.
2571
- //
2572
- // Skip these clauses in the subject scan: they don't introduce
2573
- // new subjects and they survive verbatim because the rewritten
2574
- // WHERE is built by appending trust-filter triples to the
2575
- // *original* trimmed inner (see `rewrittenBody` below) — the
2576
- // FILTER/BIND text stays exactly where the caller put it.
2577
- //
2578
- // Anti-recursion: only skip TOP-LEVEL FILTER/BIND. Nested ones
2579
- // (e.g. `FILTER EXISTS { ... }`) are already rejected by the
2580
- // `\{|\}` and `FILTER\s+EXISTS` checks at line 753 / 754, so
2581
- // by the time we reach this loop we're guaranteed to be looking
2582
- // at a flat FILTER(<expr>) or BIND(<expr> AS ?x).
2583
- const stmtTrimmed = stmt.trim();
2584
- if (/^FILTER\s*\(/i.test(stmtTrimmed) || /^BIND\s*\(/i.test(stmtTrimmed)) {
2585
- continue;
2586
- }
2587
- // First non-whitespace token is the subject. Accept:
2588
- // - variable (`?x`, `$x`)
2589
- // - absolute IRI (`<urn:x>`)
2590
- // - blank node (`_:b`)
2591
- // - RDF literal (`"…"` with optional type/lang tag)
2592
- // - prefixed name (`ex:item`) — SPARQL `PNAME_LN` / `PNAME_NS`.
2593
- // Earlier revisions fail-closed `_minTrust` to `[]` for
2594
- // every query that used standard `PREFIX ex: <urn:> …`
2595
- // syntax, which is the recommended SPARQL shape for exact
2596
- // entity lookups.
2597
- const m = stmt.match(/^\s*([?$]([A-Za-z_]\w*)|<[^>]+>|_:[A-Za-z_]\w*|"[^"]*"(?:\^\^<[^>]+>|@[A-Za-z-]+)?|[A-Za-z][\w-]*:[A-Za-z_][\w-]*|[A-Za-z][\w-]*:)/);
2598
- if (!m)
2599
- return null;
2600
- const subj = m[1];
2601
- if (subj.startsWith('?') || subj.startsWith('$')) {
2602
- subjectVars.add(subj);
2603
- continue;
2604
- }
2605
- // exact-entity lookups like `SELECT ?o WHERE { <e> <p> ?o }` are
2606
- // the most common SPARQL shape in DKG and must NOT fail closed on
2607
- // `_minTrust`. The threshold is perfectly enforceable against a
2608
- // concrete IRI: attach `<iri> <trustLevel> ?t . FILTER(?t >= N)`
2609
- // to the rewritten WHERE. Blank-node and literal subjects remain
2610
- // refused — neither can carry trust metadata in our ontology.
2611
- if (subj.startsWith('<') && subj.endsWith('>')) {
2612
- subjectIris.add(subj);
2613
- continue;
2614
- }
2615
- // Prefixed name — treat like an IRI at the clause-emission stage.
2616
- // The original query still carries the `PREFIX` declarations, so
2617
- // emitting `ex:item <trustLevel> ?t . FILTER(...)` is valid SPARQL
2618
- // at the same scope. Rejects `_:bn` (starts with `_:`) and
2619
- // string literals (start with `"`) naturally because this branch
2620
- // only runs when subj starts with a letter.
2621
- if (/^[A-Za-z]/.test(subj) && subj.includes(':')) {
2622
- subjectPrefixed.add(subj);
2623
- continue;
2624
- }
2625
- // Blank-node / literal subject — cannot attach a trust filter.
2626
- return null;
2627
- }
2628
- if (subjectVars.size === 0 && subjectIris.size === 0 && subjectPrefixed.size === 0)
2629
- return null;
2630
- const extraClauses = [];
2631
- let i = 0;
2632
- for (const subjectVar of subjectVars) {
2633
- const trustVar = `?__dkgTrust${i++}`;
2634
- extraClauses.push(`${subjectVar} <${TRUST_LEVEL_PREDICATE}> ${trustVar} . ` +
2635
- `FILTER(<http://www.w3.org/2001/XMLSchema#integer>(STR(${trustVar})) >= ${minTrust})`);
2636
- }
2637
- for (const subjectIri of subjectIris) {
2638
- const trustVar = `?__dkgTrust${i++}`;
2639
- extraClauses.push(`${subjectIri} <${TRUST_LEVEL_PREDICATE}> ${trustVar} . ` +
2640
- `FILTER(<http://www.w3.org/2001/XMLSchema#integer>(STR(${trustVar})) >= ${minTrust})`);
2641
- }
2642
- for (const subjectPfx of subjectPrefixed) {
2643
- const trustVar = `?__dkgTrust${i++}`;
2644
- extraClauses.push(`${subjectPfx} <${TRUST_LEVEL_PREDICATE}> ${trustVar} . ` +
2645
- `FILTER(<http://www.w3.org/2001/XMLSchema#integer>(STR(${trustVar})) >= ${minTrust})`);
2646
- }
2647
- // the previous implementation unconditionally inserted
2648
- // `" . "` between `inner.trim()` and the injected clauses, which
2649
- // produced `... . . ?s <trustLevel> ...` when the original WHERE
2650
- // already ended with a dot (the common case) — a SPARQL syntax error
2651
- // that every rewritten query hit. Here we emit each rewritten triple
2652
- // with its OWN dot and join them after the original inner block,
2653
- // always with exactly one separating dot regardless of whether the
2654
- // caller terminated their final triple pattern.
2655
- const endsWithDot = /\.\s*$/.test(trimmedInner);
2656
- const separator = endsWithDot ? ' ' : ' . ';
2657
- const rewrittenBody = `${trimmedInner}${separator}${extraClauses.join(' ')}`;
2658
- // if the WHERE started with a `VALUES ?s { … }` clause the
2659
- // peeler set aside, re-emit it at the top of the rewritten body so
2660
- // the bindings it introduces still drive the trust-filtered BGP.
2661
- const rewrittenInner = valuesClause
2662
- ? `${valuesClause} ${rewrittenBody}`
2663
- : rewrittenBody;
2664
- const before = sparql.slice(0, braceStart + 1);
2665
- const after = sparql.slice(braceEnd);
2666
- return `${before} ${rewrittenInner} ${after}`;
2667
- }
2668
- /**
2669
- * peel a single leading top-level `VALUES ?var { … }` clause
2670
- * off the WHERE body. Returns the clause text (verbatim, including the
2671
- * trailing `}`) and the remainder so the caller can reason about
2672
- * triples alone. If the WHERE does NOT start with a VALUES clause, or
2673
- * the VALUES clause is multi-var (`VALUES (?x ?y) { (<a> "b") }`), has
2674
- * unbalanced braces, or uses nested parentheses for row syntax, returns
2675
- * `{ valuesClause: null, bodyAfterValues: null }` so the caller falls
2676
- * back to refusing the query (the forbidden-tokens regex still trips
2677
- * on `VALUES`).
2678
- */
2679
- function peelLeadingValues(inner) {
2680
- const withoutComments = stripSparqlLineComments(inner);
2681
- const m = withoutComments.match(/^\s*VALUES\s+([?$][A-Za-z_]\w*)\s*\{/i);
2682
- if (!m)
2683
- return { valuesClause: null, bodyAfterValues: null };
2684
- const openBraceRel = m[0].length - 1;
2685
- let depth = 1;
2686
- let i = openBraceRel + 1;
2687
- let inString = false;
2688
- let inIri = false;
2689
- for (; i < withoutComments.length; i++) {
2690
- const ch = withoutComments[i];
2691
- if (inString) {
2692
- if (ch === '\\') {
2693
- i++;
2694
- continue;
2695
- }
2696
- if (ch === '"')
2697
- inString = false;
2698
- continue;
2699
- }
2700
- if (inIri) {
2701
- if (ch === '>')
2702
- inIri = false;
2703
- continue;
2704
- }
2705
- if (ch === '"') {
2706
- inString = true;
2707
- continue;
2708
- }
2709
- if (ch === '<') {
2710
- inIri = true;
2711
- continue;
2712
- }
2713
- if (ch === '(' || ch === ')') {
2714
- // Row-tuple syntax — we can't reason about multi-var rows safely.
2715
- return { valuesClause: null, bodyAfterValues: null };
2716
- }
2717
- if (ch === '{')
2718
- depth++;
2719
- else if (ch === '}') {
2720
- depth--;
2721
- if (depth === 0)
2722
- break;
2723
- }
2724
- }
2725
- if (depth !== 0)
2726
- return { valuesClause: null, bodyAfterValues: null };
2727
- const closeAbs = i;
2728
- const valuesClause = withoutComments.slice(0, closeAbs + 1).trim();
2729
- const bodyAfterValues = withoutComments.slice(closeAbs + 1);
2730
- return { valuesClause, bodyAfterValues };
2731
- }
2732
1117
  function mergeSharedMemoryAndDataResults(dataResult, smResult) {
2733
1118
  if (dataResult.type === 'quads' || smResult.type === 'quads') {
2734
1119
  const mergedQuads = dedupeQuads([
@@ -2784,29 +1169,32 @@ function dedupeQuads(quads) {
2784
1169
  * inner-UNION case in `queryMultipleGraphs`) to reject shapes that
2785
1170
  * would otherwise return duplicate / mis-ordered / over-limit rows.
2786
1171
  *
2787
- * Literals, comments, and IRI bodies are blanked first
2788
- * (`stripLiteralsAndComments`) so a keyword appearing inside a string
2789
- * literal or IRI (e.g. `"top 10 LIMIT"`) doesn't trigger a false
2790
- * positive. The aggregate check is intentionally broad — any of the
2791
- * standard SPARQL aggregate functions invalidates naive concatenation
2792
- * because the per-graph partial aggregates can't be combined post-hoc.
1172
+ * Consume the canonical prepared token stream so active UCHAR spellings are
1173
+ * decoded exactly as they will be for execution while strings, comments, and
1174
+ * IRI bodies remain opaque. The aggregate check is intentionally broad — any
1175
+ * standard aggregate invalidates naive concatenation because per-graph
1176
+ * partial aggregates cannot be combined post-hoc.
2793
1177
  */
2794
- function hasCrossGraphUnsafeModifier(sparql) {
2795
- const s = stripLiteralsAndComments(sparql);
2796
- if (/\bDISTINCT\b/i.test(s))
2797
- return true;
2798
- if (/\bORDER\s+BY\b/i.test(s))
2799
- return true;
2800
- if (/\bGROUP\s+BY\b/i.test(s))
2801
- return true;
2802
- if (/\bHAVING\b/i.test(s))
2803
- return true;
2804
- if (/\bLIMIT\b/i.test(s))
2805
- return true;
2806
- if (/\bOFFSET\b/i.test(s))
2807
- return true;
2808
- if (/\b(COUNT|SUM|AVG|MIN|MAX|SAMPLE|GROUP_CONCAT)\s*\(/i.test(s))
2809
- return true;
1178
+ function hasCrossGraphUnsafeModifier(scope) {
1179
+ const tokens = scope.prepared.tokens;
1180
+ const singleWordModifiers = new Set(['DISTINCT', 'HAVING', 'LIMIT', 'OFFSET']);
1181
+ const aggregates = new Set(['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'SAMPLE', 'GROUP_CONCAT']);
1182
+ for (let index = 0; index < tokens.length; index++) {
1183
+ const token = tokens[index];
1184
+ if (token?.kind !== 'word')
1185
+ continue;
1186
+ if (singleWordModifiers.has(token.upper))
1187
+ return true;
1188
+ const next = tokens[index + 1];
1189
+ if ((token.upper === 'ORDER' || token.upper === 'GROUP')
1190
+ && next?.kind === 'word'
1191
+ && next.upper === 'BY')
1192
+ return true;
1193
+ if (aggregates.has(token.upper)
1194
+ && next?.kind === 'symbol'
1195
+ && next.logicalValue === '(')
1196
+ return true;
1197
+ }
2810
1198
  return false;
2811
1199
  }
2812
1200
  //# sourceMappingURL=dkg-query-engine.js.map