Files
semantica/semantica/triplet_store/query_engine.py
T
Mohd KaifandSameer6305 b59211ea7f security: SHA-pin all Actions, harden release pipeline, add pin verification (#824)
* security: SHA-pin all Actions, harden release pipeline, add pin verification

Hardens the CI/CD supply chain against the LiteLLM/Trivy-style attack (a
compromised third-party Action with a mutable tag stealing a long-lived
publishing token) and closes several related gaps found in an audit of the
actual repository state.

- Pin every third-party GitHub Action across all workflows to a full commit
  SHA (tag kept as a trailing comment); add verify-action-pins.yml, a CI
  check that confirms via the GitHub API that each pin still matches its
  tag, on every workflow change, push to main, and weekly.
- Scope release.yml permissions to the job level (workflow defaults to
  contents: read); add a concurrency group so simultaneous tag pushes can't
  race the publish job.
- Add SLSA build provenance attestation (actions/attest-build-provenance)
  for every released wheel.
- Fix a latent bug in security-scan.yml: the PR-comment step was missing
  pull-requests: write and silently failing; add bounded artifact retention
  for uploaded scan reports.
- Group github-actions Dependabot updates to cut review noise.
- Document the resulting posture in SECURITY.md for auditors/regulated
  adopters, including what's enforced and what a fork needs to reconfigure
  for itself (environment/branch protection, Trusted Publishing trust).

Also (via GitHub API, not in this diff): created a protected `pypi`
environment with a required reviewer restricted to v* tags, and enabled
branch protection on main (required review, required status checks, no
force-push/deletion).

* fix: harden verify-action-pins per PR #824 bot review

Addresses real findings from the automated review on #824:

- The script previously only matched uses: lines that already contained a
  40-hex SHA, so a newly added mutable-tag action (e.g. some/action@v1)
  would never be scanned at all and the check would pass silently. It now
  matches every uses: line and hard-fails on any ref that isn't a full
  commit SHA.
- A tag that fails to resolve via the GitHub API (rate limit, deleted tag)
  previously only logged a warning and continued; that's now a hard
  failure too, since an unverifiable pin is exactly the failure mode this
  check exists to catch.
- verify-action-pins.yml only triggered on .github/workflows/** changes,
  so an edit to the verifier script itself wouldn't run the check that
  verifies it. Added the script path to both trigger filters.

The reviewer's claim that slash-containing tag comments (release/v1) break
the API lookup did not reproduce - tested directly against
pypa/gh-action-pypi-publish@release/v1 and GitHub's commits API resolves
multi-segment refs natively - so no change was needed there.

Verified with a synthetic test workflow containing a mutable-tag action,
a correctly-pinned SHA, and a deliberately mismatched SHA: the updated
script now catches the first and third cases and passes the second. Also
re-ran against the real workflow tree (40/40 pins still verify clean).

* fix: repair broken Safety scan and PR comment formatting

The "Comment PR with Security Results" step was producing garbled output
(literal \n characters instead of newlines, "undefined:" labels) because:

- Every line in the JS comment builder used \n (escaped backslash-n)
  inside template literals, which JS renders as the literal two-character
  string \n, not a newline.
- The Semgrep section read issue.rule_id, but Semgrep's JSON field is
  check_id - hence "undefined: <path>" for every entry.

Rewrote the comment builder to construct each section as an array of
lines joined with a real '\n', with correct field names, and collapsed
long finding lists into a <details> block instead of a flat list.
Verified by extracting the exact script and running it under node against
synthetic fixtures matching each tool's real JSON schema (found/clean/
missing-report paths all render correctly).

While tracing the "undefined" and always-empty Safety section, found the
Safety step itself was silently broken:

- `safety check --json --output safety-report.json` is invalid in
  Safety 3.x: --output now selects a console format (json/text/screen),
  not a file path. The command errored on every run (swallowed by
  `|| true`), so safety-report.json was never created and the PR comment
  always fell back to a generic "scan completed" message. Switched to
  `--save-json`, which is the correct flag for writing a JSON report to
  disk, and confirmed against the real safety 3.8.1 CLI locally.
- Even with a report, the code read vuln.package - the real field is
  package_name.
- The job never installed Semantica's own dependencies before scanning,
  so `safety check` (which defaults to scanning the environment) was
  auditing the scanner tools' own dependencies, not Semantica's. Added
  `pip install -e ".[llm-litellm]"` so the project's actual dependency
  tree - including the LiteLLM extra this whole hardening effort is
  about - is what gets scanned.

Also updated the corresponding SECURITY.md bullet to describe what Safety
actually covers now.

* fix: remove unused pypdf2 dependency (CVE-2023-36464)

Now that the Safety scan step actually runs (see previous commit), it
correctly failed this PR's checks on CVE-2023-36464 in pypdf2==3.0.1 - a
real, pre-existing vulnerability that was invisible until the scan was
fixed.

PyPDF2 is not a patchable dependency here: the project is discontinued
(merged into `pypdf`), 3.0.1 is its final release, and there is no fixed
version to upgrade to. Grepping the repo for `import PyPDF2` / `from
PyPDF2` turns up nothing - it was never actually imported anywhere. Its
only presence outside pyproject.toml was in docstrings describing a
"PyPDF2.PdfReader() fallback" for PDF parsing that was never implemented
in code; pdfplumber is the library actually used. Removed the dependency
and corrected the stale docstrings in parse/__init__.py, parse/methods.py,
parse/pdf_parser.py, and ingest/email_ingestor.py accordingly.

* fix: suppress Bandit B324 false positives on non-cryptographic MD5 use

Same pattern as the previous pypdf2 commit: fixing the Safety scan
surfaced this PR's own Bandit HIGH-severity gate actually blocking on 10
pre-existing findings, all Bandit B324 ("Use of weak MD5 hash for
security").

Checked each of the 10 call sites: every one uses hashlib.md5() to build
a short deterministic cache key, entity ID, or IRI suffix from already-
non-secret input (query text, entity text/type, class/property names) -
none are used for passwords, tokens, or integrity verification of
untrusted data. This is exactly the case Bandit's own message points at
("Consider usedforsecurity=False").

Did not use usedforsecurity=False itself: that keyword argument was
added to hashlib in Python 3.9, and pyproject.toml declares
`requires-python = ">=3.8"` - adding it unconditionally risks a TypeError
on 3.8. Used a targeted `# nosec B324` comment with a one-line
justification instead, which suppresses only this specific check and
carries no runtime behavior change on any supported Python version.

Verified locally: bandit -r semantica/ -ll now reports 0 HIGH-severity
findings (was 10).

* docs: add CHANGELOG entry for #824 CI/CD supply-chain hardening

Covers the SHA-pinning + verify-action-pins.yml enforcement, release.yml
hardening (job-scoped permissions, concurrency, SLSA provenance), the
pypi environment/branch protection GitHub-side config, the
security-scan.yml Safety/comment-formatting fixes, and the two
vulnerabilities those fixes surfaced (pypdf2 CVE-2023-36464 removal,
Bandit B324 suppression).

* fix: close two remaining gaps missed by upstream bot-review fixes

verify-action-pins.sh:
- Quoted uses: lines (e.g. uses: owner/action@SHA) were not matched
  by the existing regex, so a SHA-pinned action written with quotes would
  silently skip verification. Updated the main ERE to accept an optional
  leading/trailing single or double quote around the owner/action@ref
  value, and excluded quote chars from the inner character classes so the
  ref is still extracted cleanly.
- The grep input glob only covered *.yml. GitHub also treats *.yaml as a
  valid workflow extension. Added *.yaml to the glob and a 2>/dev/null
  guard so the command doesn't fail when no *.yaml files exist.

security-scan.yml (on top of Kaif's --save-json fix in 67c7ec2a):
- Kaif's fix kept the '|| echo 0' fallback on the VULNS= line, so all
  five scanner-failure modes (file missing, empty file, malformed JSON,
  valid JSON with no 'vulnerabilities' key, vulnerabilities: null) still
  silently produce VULNS=0 or VULNS=null and pass the merge-blocker check.
- Added guard 1: '[ ! -s safety-report.json ]' fails loudly if Safety
  crashed before writing a report (covers missing and empty-file cases).
- Dropped the '|| echo 0' fallback and added guard 2: '[[ ! VULNS =~
  ^[0-9]+$ ]]' fails loudly on non-integer VULNS (covers malformed JSON,
  missing key, and null cases). Both guards emit ::error:: annotations.
- Verified with a 7-case simulation: all 5 failure modes now exit 1;
  genuine zero-vuln and real-vuln cases still behave correctly.

* fix: correct bash [[ =~ ]] quoting that broke verify-action-pins.sh in CI

The regex for matching uses: lines was embedded directly inline in a
[[ =~ ]] test with literal \" and \' escape sequences. Bash's conditional-
expression parser interprets these as shell syntax rather than regex
literals, producing:

  syntax error in conditional expression: unexpected token ')'

at line 27 on every CI run.

Fix: move the regex into a USES_PATTERN variable using safe single-quote
shell-string concatenation so the [[ =~ ]] parser receives an unquoted
variable reference ($USES_PATTERN) rather than a literal pattern containing
bash-special characters. The regex semantics are identical: optional
leading/trailing quote around owner/action@ref, quote chars excluded from
capture groups.

Verified in real bash 5.2.21 (Git for Windows):
  No syntax error on the real 40-pin workflow tree (Checked 40)
  Unquoted SHA pin:      MATCH, correct repo+ref extracted
  Double-quoted SHA pin: MATCH, correct repo+ref extracted
  Single-quoted SHA pin: MATCH, correct repo+ref extracted
  .yaml extension file:  MATCH, correct repo+ref extracted
  ./local-action:        NO MATCH (correct)
  docker://:             NO MATCH (correct)

* docs: add 3 missing items to fork-reconfiguration checklist in SECURITY.md

The checklist covered Trusted Publishing trust, protected environment,
branch protection, and Dependabot github-actions entry. Three non-forking
controls described elsewhere in SECURITY.md were omitted:

- GitHub secret scanning and push protection (repo settings, not copied
  on fork)
- GitGuardian (GitHub App installation scoped to this specific repo,
  requires separate install on any fork)
- CodeQL Default Setup vs Advanced Setup state (repo setting that affects
  whether the upload-sarif step in codeql.yml does anything)

Added as items 5, 6, 7 matching the existing numbered bullet style.

* fix: update github/codeql-action pins to v4 tip (SHA drift caught by verify check)

verify-action-pins caught that github/codeql-action@v4 tag was re-pointed
upstream:

  old: f205ea1c3313d32999d8d6a48b4f6530d4437b38
  new: d1ba80a13dd99fba24a470575428917156a28b43

Updated all 8 occurrences across codeql.yml (init x3, autobuild, analyze,
upload-sarif) and defender-for-devops.yml (upload-sarif x2). Tag comment
# v4 unchanged — the tag itself hasn't changed, only what commit it points to.

---------

Co-authored-by: Sameer6305 <sskadam6305@gmail.com>
2026-08-03 19:17:10 +05:30

572 lines
20 KiB
Python

"""
Query Engine Module
This module provides comprehensive SPARQL query execution and optimization
for triplet store operations, including query planning, caching, and performance
monitoring.
Key Features:
- SPARQL query execution and optimization
- Query planning and caching
- Result processing and formatting
- Performance monitoring and profiling
- Query validation
- Multi-store query support
- Query history tracking
Main Classes:
- QueryEngine: Main query execution and optimization coordinator
- QueryResult: SPARQL query result representation dataclass
- QueryPlan: Query execution plan representation dataclass
Example Usage:
>>> from semantica.triplet_store import QueryEngine
>>> engine = QueryEngine(enable_caching=True, enable_optimization=True)
>>> result = engine.execute_query(sparql_query, store_backend)
>>> plan = engine.plan_query(sparql_query)
>>> stats = engine.get_query_statistics()
Author: Semantica Contributors
License: MIT
"""
import time
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
@dataclass
class QueryResult:
"""SPARQL query result."""
bindings: List[Dict[str, Any]]
variables: List[str]
execution_time: float = 0.0
metadata: Dict[str, Any] = field(default_factory=dict)
triples: List[tuple] = field(default_factory=list)
"""Populated only for CONSTRUCT queries. Each element is a (subject,
predicate, object, metadata) 4-tuple, taken directly from the store
backend's execute_sparql "triples" key (see BlazegraphStore.execute_sparql
CONSTRUCT path). subject/predicate/object are strings; metadata is a dict
that is empty ({}) for URIs and plain untyped/unlang-tagged literals, and
otherwise carries "datatype" and/or "language" keys for literals that
have that information, so it is not silently lost. Empty list for all
SELECT/ASK/DESCRIBE queries and for backends without CONSTRUCT support."""
@dataclass
class QueryPlan:
"""Query execution plan."""
query: str
optimized_query: str
estimated_cost: float = 0.0
execution_steps: List[str] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
class QueryEngine:
"""
SPARQL query execution and optimization engine.
• SPARQL query execution and optimization
• Query planning and caching
• Result processing and formatting
• Performance monitoring and profiling
• Error handling and validation
• Multi-store query support
"""
def __init__(self, config: Optional[Dict[str, Any]] = None, **kwargs):
"""
Initialize query engine.
Args:
config: Configuration dictionary
**kwargs: Additional configuration options:
- enable_caching: Enable query caching (default: True)
- cache_size: Cache size limit
- enable_optimization: Enable query optimization (default: True)
"""
self.logger = get_logger("query_engine")
self.config = config or {}
self.config.update(kwargs)
self.progress_tracker = get_progress_tracker()
# Ensure progress tracker is enabled
if not self.progress_tracker.enabled:
self.progress_tracker.enabled = True
self.enable_caching = self.config.get("enable_caching", True)
self.enable_optimization = self.config.get("enable_optimization", True)
self.cache_size = self.config.get("cache_size", 1000)
self.query_cache: Dict[str, QueryResult] = {}
self.query_history: List[Dict[str, Any]] = []
def execute_query(self, query: str, store_backend: Any, **options) -> QueryResult:
"""
Execute SPARQL query.
Args:
query: SPARQL query string
store_backend: Triplet store backend instance
**options: Additional options
Returns:
Query result
"""
tracking_id = self.progress_tracker.start_tracking(
module="triplet_store",
submodule="QueryEngine",
message="Executing SPARQL query",
)
try:
start_time = time.time()
supports_named_graphs = options.get("supports_named_graphs")
if supports_named_graphs is None:
supports_named_graphs = getattr(store_backend, "supports_named_graphs", True)
prepared_query = self.prepare_query(
query,
graph=options.get("graph"),
graphs=options.get("graphs"),
supports_named_graphs=supports_named_graphs,
)
# Validate query
self.progress_tracker.update_tracking(
tracking_id, message="Validating query..."
)
if not self._validate_query(prepared_query):
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message="Invalid SPARQL query"
)
raise ValidationError("Invalid SPARQL query")
# Check cache
if self.enable_caching:
self.progress_tracker.update_tracking(
tracking_id, message="Checking cache..."
)
cache_key = self._get_cache_key(prepared_query)
if cache_key in self.query_cache:
self.logger.debug("Returning cached query result")
cached_result = self.query_cache[cache_key]
cached_result.metadata["cached"] = True
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message="Returned cached result",
)
return cached_result
# Optimize query
if self.enable_optimization:
self.progress_tracker.update_tracking(
tracking_id, message="Optimizing query..."
)
optimized_query = self.optimize_query(prepared_query, **options)
else:
optimized_query = prepared_query
# Execute query
self.progress_tracker.update_tracking(
tracking_id, message="Executing query on store..."
)
if hasattr(store_backend, "execute_sparql"):
result_data = store_backend.execute_sparql(optimized_query, **options)
else:
raise ProcessingError("Store backend does not support SPARQL execution")
execution_time = time.time() - start_time
result = QueryResult(
bindings=result_data.get("bindings", []),
variables=result_data.get("variables", []),
execution_time=execution_time,
triples=result_data.get("triples", []),
metadata={
**result_data.get("metadata", {}),
"optimized": optimized_query != prepared_query,
"cached": False,
"graph": options.get("graph"),
"graphs": options.get("graphs") or [],
},
)
# Cache result
if self.enable_caching:
self.progress_tracker.update_tracking(
tracking_id, message="Caching result..."
)
self._cache_result(prepared_query, result)
# Record history
self.query_history.append(
{
"query": prepared_query,
"execution_time": execution_time,
"result_count": len(result.bindings),
"timestamp": datetime.now().isoformat(),
}
)
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message=f"Query executed: {len(result.bindings)} results in {execution_time:.2f}s",
)
return result
except (ValidationError, ProcessingError):
raise
except Exception as e:
execution_time = (
time.time() - start_time if "start_time" in locals() else 0.0
)
self.logger.error(f"Query execution failed: {e}")
self.progress_tracker.stop_tracking(
tracking_id, status="failed", message=str(e)
)
raise ProcessingError(f"Query execution failed: {e}")
def prepare_query(
self,
query: str,
graph: Optional[str] = None,
graphs: Optional[List[str]] = None,
supports_named_graphs: bool = True,
) -> str:
"""Prepare query with optional graph dataset clauses."""
if not query:
return ""
resolved_graph = (
graph
or self.config.get("default_graph")
or self.config.get("default_graph_uri")
)
resolved_graphs = graphs
if resolved_graphs is None:
resolved_graphs = self.config.get("default_graphs")
if isinstance(resolved_graphs, str):
resolved_graphs = [resolved_graphs]
resolved_graphs = [g for g in (resolved_graphs or []) if g]
if resolved_graph and resolved_graph in resolved_graphs:
# Preserve graph as default dataset while avoiding duplicate URIs in FROM NAMED.
resolved_graphs = [g for g in resolved_graphs if g != resolved_graph]
if not supports_named_graphs and (resolved_graph or resolved_graphs):
self.logger.warning(
"Named graph options were provided but backend does not support named graphs; "
"falling back to backend default dataset"
)
return query.strip()
return self._inject_graph_clauses(
query,
graph=resolved_graph,
graphs=resolved_graphs,
)
def _inject_graph_clauses(
self,
query: str,
graph: Optional[str] = None,
graphs: Optional[List[str]] = None,
) -> str:
"""Inject FROM/FROM NAMED clauses immediately before WHERE."""
normalized_query = query.strip()
graph_list = [g for g in (graphs or []) if g]
if not graph and not graph_list:
return normalized_query
if re.search(r"\bFROM\b", normalized_query, flags=re.IGNORECASE):
return normalized_query
if not re.search(
r"\b(SELECT|ASK|CONSTRUCT|DESCRIBE)\b",
normalized_query,
flags=re.IGNORECASE,
):
return normalized_query
where_match = re.search(r"\bWHERE\b", normalized_query, flags=re.IGNORECASE)
if not where_match:
return normalized_query
dataset_clauses: List[str] = []
if graph:
safe_graph = self._sanitize_uri(graph)
dataset_clauses.append(f"FROM <{safe_graph}>")
for graph_uri in graph_list:
safe_graph = self._sanitize_uri(graph_uri)
dataset_clauses.append(f"FROM NAMED <{safe_graph}>")
if not dataset_clauses:
return normalized_query
before_where = normalized_query[: where_match.start()].rstrip()
where_and_after = normalized_query[where_match.start() :].lstrip()
dataset_block = "\n".join(dataset_clauses)
return f"{before_where}\n{dataset_block}\n{where_and_after}"
def optimize_query(self, query: str, **options) -> str:
"""
Optimize SPARQL query.
Args:
query: Original query
**options: Optimization options
Returns:
Optimized query
"""
optimized = query.strip()
# Remove unnecessary whitespace
optimized = " ".join(optimized.split())
# Add LIMIT if SELECT query doesn't have one
if "SELECT" in optimized.upper() and "LIMIT" not in optimized.upper():
if options.get("add_limit", True):
default_limit = options.get("default_limit", 1000)
optimized += f" LIMIT {default_limit}"
# Basic query rewriting
# (More sophisticated optimization would require query parser)
return optimized
def plan_query(self, query: str, **options) -> QueryPlan:
"""
Create query execution plan.
Args:
query: SPARQL query
**options: Planning options
Returns:
Query execution plan
"""
optimized_query = (
self.optimize_query(query, **options) if self.enable_optimization else query
)
# Estimate cost (simplified)
estimated_cost = self._estimate_query_cost(query)
# Identify execution steps
execution_steps = self._identify_execution_steps(query)
return QueryPlan(
query=query,
optimized_query=optimized_query,
estimated_cost=estimated_cost,
execution_steps=execution_steps,
metadata={"optimization_enabled": self.enable_optimization},
)
def expand_entity_uri(self, entity_uri: str, store_backend: Any, use_alignments: bool = False) -> List[str]:
"""
Expand an entity URI to include all aligned/equivalent entities.
Args:
entity_uri: The original URI to expand
store_backend: Triplet store backend to query
use_alignments: If False, returns only the original URI
Returns:
List of URIs including the original and any aligned entities
"""
if not use_alignments:
return [entity_uri]
tracking_id = self.progress_tracker.start_tracking(
module="triplet_store",
submodule="QueryEngine",
message=f"Expanding alignments for: {entity_uri}"
)
# SPARQL query to find bidirectional alignments
safe_uri = self._sanitize_uri(entity_uri)
query = f"""
SELECT DISTINCT ?aligned WHERE {{
{{ <{safe_uri}> ?p ?aligned }}
UNION
{{ ?aligned ?p <{safe_uri}> }}
FILTER (?p IN (
<http://www.w3.org/2002/07/owl#equivalentClass>,
<http://www.w3.org/2002/07/owl#equivalentProperty>,
<http://www.w3.org/2002/07/owl#sameAs>,
<http://www.w3.org/2004/02/skos/core#exactMatch>,
<http://www.w3.org/2004/02/skos/core#closeMatch>,
<http://www.w3.org/2004/02/skos/core#broadMatch>,
<http://www.w3.org/2004/02/skos/core#narrowMatch>,
<http://www.w3.org/2004/02/skos/core#relatedMatch>
))
}}
"""
expanded_uris = {entity_uri}
try:
if hasattr(store_backend, "execute_sparql"):
result_data = store_backend.execute_sparql(query)
for binding in result_data.get("bindings", []):
val = binding.get("aligned", {})
uri = val.get("value") if isinstance(val, dict) else val
if uri:
expanded_uris.add(uri)
else:
self.logger.warning(
"store_backend does not support execute_sparql; returning original URI only"
)
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message=f"Expanded to {len(expanded_uris)} URIs"
)
except Exception as e:
self.logger.error(f"Failed to expand alignments for {entity_uri}: {e}")
self.progress_tracker.stop_tracking(tracking_id, status="failed", message=str(e))
return list(expanded_uris)
def build_values_clause(self, variable_name: str, uris: List[str]) -> str:
"""
Helper to generate a SPARQL VALUES clause for a list of URIs.
Allows higher-level components to build alignment-aware queries.
Example:
uris = engine.expand_entity_uri("http://ex.org/Person", store, use_alignments=True)
clause = engine.build_values_clause("subject", uris)
# Returns: VALUES ?subject { <http://ex.org/Person> <http://other.org/Human> }
"""
if not uris:
return ""
formatted_uris = " ".join([f"<{self._sanitize_uri(uri)}>" for uri in uris])
return f"VALUES ?{variable_name} {{ {formatted_uris} }}"
def _validate_query(self, query: str) -> bool:
"""Validate SPARQL query syntax (basic)."""
if not query or not query.strip():
return False
query_upper = query.upper()
# Check for valid SPARQL keywords
valid_keywords = [
"SELECT",
"ASK",
"CONSTRUCT",
"DESCRIBE",
"INSERT",
"DELETE",
"WHERE",
]
if not any(keyword in query_upper for keyword in valid_keywords):
return False
return True
def _estimate_query_cost(self, query: str) -> float:
"""Estimate query execution cost."""
# Simple heuristic based on query complexity
cost = 1.0
# COUNT queries are more expensive
if "COUNT" in query.upper():
cost *= 2.0
# Multiple joins increase cost
join_count = query.upper().count("JOIN") + query.count(".")
cost *= 1.0 + join_count * 0.1
# DISTINCT increases cost
if "DISTINCT" in query.upper():
cost *= 1.5
return cost
def _identify_execution_steps(self, query: str) -> List[str]:
"""Identify query execution steps."""
steps = []
query_upper = query.upper()
if "SELECT" in query_upper:
steps.append("SELECT projection")
if "WHERE" in query_upper:
steps.append("Pattern matching")
if "FILTER" in query_upper:
steps.append("Filtering")
if "ORDER BY" in query_upper:
steps.append("Sorting")
if "LIMIT" in query_upper or "OFFSET" in query_upper:
steps.append("Pagination")
return steps
def _get_cache_key(self, query: str) -> str:
"""Generate cache key for query."""
import hashlib
normalized = " ".join(query.split())
return hashlib.md5(normalized.encode()).hexdigest() # nosec B324 - cache key, not security-sensitive
def _cache_result(self, query: str, result: QueryResult) -> None:
"""Cache query result."""
if len(self.query_cache) >= self.cache_size:
# Remove oldest entry
oldest_key = next(iter(self.query_cache))
del self.query_cache[oldest_key]
cache_key = self._get_cache_key(query)
self.query_cache[cache_key] = result
def _sanitize_uri(self, uri: str) -> str:
"""Prevent SPARQL injection by percent-encoding dangerous characters."""
if not isinstance(uri, str):
return ""
return uri.replace("<", "%3C").replace(">", "%3E")
def clear_cache(self) -> None:
"""Clear query cache."""
self.query_cache.clear()
def get_query_statistics(self) -> Dict[str, Any]:
"""Get query execution statistics."""
if not self.query_history:
return {
"total_queries": 0,
"average_execution_time": 0.0,
"total_execution_time": 0.0,
}
execution_times = [q["execution_time"] for q in self.query_history]
return {
"total_queries": len(self.query_history),
"average_execution_time": sum(execution_times) / len(execution_times),
"total_execution_time": sum(execution_times),
"min_execution_time": min(execution_times),
"max_execution_time": max(execution_times),
"cache_size": len(self.query_cache),
"cache_hit_rate": 0.0, # Would need to track hits/misses
}