mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
* 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>
572 lines
20 KiB
Python
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
|
|
}
|