mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Add SPARQL CONSTRUCT query templates (Blazegraph-only)
Implements #322: ConstructTemplate/ParameterDescriptor/ConstructTemplateRegistry with injection-safe {{param}} rendering, Blazegraph CONSTRUCT-aware execute_sparql extension, execute_construct_template (render->execute->parse->persist), and a construct_template pipeline step. RDF4J/Jena support deferred to a follow-up issue. Closes #322
This commit is contained in:
BIN
Binary file not shown.
@@ -495,6 +495,40 @@ result = engine.execute_pipeline(
|
|||||||
Delta detection uses SHA-256 checksums on source content. Only sources whose checksum differs from `base_version_id` are passed to downstream steps. For pipelines that run hourly or daily against a growing corpus, delta mode eliminates redundant re-embedding and re-extraction.
|
Delta detection uses SHA-256 checksums on source content. Only sources whose checksum differs from `base_version_id` are passed to downstream steps. For pipelines that run hourly or daily against a growing corpus, delta mode eliminates redundant re-embedding and re-extraction.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
|
## SPARQL CONSTRUCT Template Steps
|
||||||
|
|
||||||
|
Use the `"construct_template"` step type to render and execute a [SPARQL CONSTRUCT template](triplet_store#sparql-construct-templates) as part of a pipeline. `store_backend` and `construct_template_registry` are execution-time resources, not step config — pass them to `execute_pipeline()`, the same way `delta_mode` steps receive `version_manager` and `triplet_store`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from semantica.pipeline import PipelineBuilder, ExecutionEngine
|
||||||
|
from semantica.triplet_store.construct_templates import construct_template_step_handler
|
||||||
|
|
||||||
|
builder = PipelineBuilder()
|
||||||
|
builder.add_step(
|
||||||
|
"apply_person_template",
|
||||||
|
"construct_template",
|
||||||
|
handler=construct_template_step_handler,
|
||||||
|
template_name="person_to_foaf",
|
||||||
|
params={"subject": "http://ex.org/p1", "name": "Alice", "age": 30},
|
||||||
|
target_graph="http://ex.org/graphs/people",
|
||||||
|
)
|
||||||
|
pipeline = builder.build("person_pipeline")
|
||||||
|
|
||||||
|
engine = ExecutionEngine()
|
||||||
|
result = engine.execute_pipeline(
|
||||||
|
pipeline,
|
||||||
|
data=None,
|
||||||
|
store_backend=store, # required: a BlazegraphStore instance
|
||||||
|
construct_template_registry=registry, # required: holds the registered template
|
||||||
|
)
|
||||||
|
|
||||||
|
triplets = result.output # List[Triplet], already persisted via store.add_triplets
|
||||||
|
```
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
`construct_template` steps raise `ProcessingError` if `store_backend` or `construct_template_registry` is missing from `execute_pipeline()`'s options, and `ValidationError` if `template_name` isn't registered.
|
||||||
|
</Note>
|
||||||
|
|
||||||
## Schemas
|
## Schemas
|
||||||
|
|
||||||
<AccordionGroup>
|
<AccordionGroup>
|
||||||
|
|||||||
@@ -277,6 +277,65 @@ store.execute_query("""
|
|||||||
**`execute_query()` returns `QueryResult`, not a list.** Iterate `result.bindings`, not `result` directly. Each binding is a dict mapping variable name → `{"value": ..., "type": ...}`.
|
**`execute_query()` returns `QueryResult`, not a list.** Iterate `result.bindings`, not `result` directly. Each binding is a dict mapping variable name → `{"value": ..., "type": ...}`.
|
||||||
</Warning>
|
</Warning>
|
||||||
|
|
||||||
|
## SPARQL CONSTRUCT Templates
|
||||||
|
|
||||||
|
`semantica.triplet_store.construct_templates` provides parameterized SPARQL `CONSTRUCT` query templates: define a reusable query once, substitute typed parameters safely, and persist the resulting triples in one call. This is available for the **Blazegraph backend only** (see [Backends](#backends) above) — `BlazegraphStore.execute_sparql()` is the only backend with CONSTRUCT-aware RDF parsing.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from semantica.triplet_store.construct_templates import (
|
||||||
|
ConstructTemplate,
|
||||||
|
ParameterDescriptor,
|
||||||
|
ConstructTemplateRegistry,
|
||||||
|
render_construct_template,
|
||||||
|
execute_construct_template,
|
||||||
|
)
|
||||||
|
from semantica.triplet_store import BlazegraphStore
|
||||||
|
|
||||||
|
# Define and register a template
|
||||||
|
template = ConstructTemplate(
|
||||||
|
name="person_to_foaf",
|
||||||
|
description="Maps a person record subject to a foaf:name triple",
|
||||||
|
construct_query="""
|
||||||
|
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
|
||||||
|
CONSTRUCT { <{{subject}}> foaf:name {{name}} ; foaf:age {{age}} }
|
||||||
|
WHERE { <{{subject}}> a <http://ex.org/Person> }
|
||||||
|
""",
|
||||||
|
parameters=[
|
||||||
|
ParameterDescriptor(name="subject", type="uri", required=True),
|
||||||
|
ParameterDescriptor(name="name", type="literal", required=True),
|
||||||
|
ParameterDescriptor(
|
||||||
|
name="age", type="typed-literal", required=False, default=0,
|
||||||
|
datatype="xsd:integer",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
registry.register(template)
|
||||||
|
|
||||||
|
# Render only: inspect the substituted SPARQL string, no network call
|
||||||
|
sparql = render_construct_template(
|
||||||
|
registry.get("person_to_foaf"),
|
||||||
|
params={"subject": "http://ex.org/p1", "name": "Alice", "age": 30},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Render + execute + persist in one call
|
||||||
|
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph", namespace="kb")
|
||||||
|
triplets = execute_construct_template(
|
||||||
|
template=registry.get("person_to_foaf"),
|
||||||
|
params={"subject": "http://ex.org/p1", "name": "Alice", "age": 30},
|
||||||
|
store_backend=store,
|
||||||
|
target_graph="http://ex.org/graphs/people",
|
||||||
|
)
|
||||||
|
# triplets: List[Triplet], already persisted via store.add_triplets
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `ParameterDescriptor.type` controls how its value is rendered: `"uri"` values are validated against an allowlist and wrapped in `<...>`, `"literal"` values are escaped and quoted, and `"typed-literal"` values require a `datatype` (e.g. `"xsd:integer"`) and render unquoted for numeric/boolean XSD types. Placeholders use `{{param}}` rather than SPARQL's own `?param` syntax so template placeholders are never confused with real SPARQL variables in the query body.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
CONSTRUCT templates are Blazegraph-only. `execute_construct_template()` raises `ProcessingError` if `store_backend` does not implement both `execute_sparql()` and `add_triplets()`.
|
||||||
|
</Note>
|
||||||
|
|
||||||
## SPARQL Result Pagination
|
## SPARQL Result Pagination
|
||||||
|
|
||||||
For large result sets, paginate with LIMIT and OFFSET:
|
For large result sets, paginate with LIMIT and OFFSET:
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ from typing import Any, Dict, List, Optional
|
|||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from rdflib import Graph
|
||||||
|
|
||||||
from ..semantic_extract.triplet_extractor import Triplet
|
from ..semantic_extract.triplet_extractor import Triplet
|
||||||
from ..utils.exceptions import ProcessingError, ValidationError
|
from ..utils.exceptions import ProcessingError, ValidationError
|
||||||
from ..utils.logging import get_logger
|
from ..utils.logging import get_logger
|
||||||
from ..utils.progress_tracker import get_progress_tracker
|
from ..utils.progress_tracker import get_progress_tracker
|
||||||
|
from . import sparql_escaping
|
||||||
|
|
||||||
|
_CONSTRUCT_QUERY_RE = re.compile(r"\bCONSTRUCT\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
class BlazegraphStore:
|
class BlazegraphStore:
|
||||||
@@ -111,16 +115,42 @@ class BlazegraphStore:
|
|||||||
"""Get SPARQL Update endpoint URL."""
|
"""Get SPARQL Update endpoint URL."""
|
||||||
return urljoin(self.endpoint, f"/blazegraph/namespace/{self.namespace}/sparql")
|
return urljoin(self.endpoint, f"/blazegraph/namespace/{self.namespace}/sparql")
|
||||||
|
|
||||||
|
def _is_construct_query(self, query: str) -> bool:
|
||||||
|
"""
|
||||||
|
Detect whether `query` is a SPARQL CONSTRUCT query.
|
||||||
|
|
||||||
|
This is a dispatch helper local to BlazegraphStore, distinct from
|
||||||
|
QueryEngine._validate_query (which already treats CONSTRUCT as one of
|
||||||
|
its valid_keywords and therefore requires no change — CONSTRUCT
|
||||||
|
queries already pass validation today). This helper only decides
|
||||||
|
which HTTP Accept header and response parser execute_sparql uses; it
|
||||||
|
does not gate query validity.
|
||||||
|
"""
|
||||||
|
return _CONSTRUCT_QUERY_RE.search(query) is not None
|
||||||
|
|
||||||
def execute_sparql(self, query: str, **options) -> Dict[str, Any]:
|
def execute_sparql(self, query: str, **options) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Execute SPARQL query.
|
Execute SPARQL query.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: SPARQL query string
|
query: SPARQL query string
|
||||||
**options: Additional options
|
**options: Additional options:
|
||||||
|
- result_format: Optional[Literal["bindings", "construct"]].
|
||||||
|
If omitted, auto-detected via _is_construct_query(query).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Query results
|
Query results. For non-CONSTRUCT queries (or when result_format
|
||||||
|
resolves to "bindings"), the existing shape is unchanged:
|
||||||
|
{"success": bool, "bindings": [...], "variables": [...], "metadata": {...}}
|
||||||
|
For CONSTRUCT queries (or result_format="construct"), the shape is:
|
||||||
|
{"success": bool, "bindings": [], "variables": [], "triples": [...],
|
||||||
|
"metadata": {...}}
|
||||||
|
where "triples" is a list of (subject, predicate, object) string
|
||||||
|
3-tuples parsed from the Turtle response via rdflib.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessingError: if not connected, the HTTP request fails, or (for
|
||||||
|
CONSTRUCT queries) the response body fails to parse as Turtle.
|
||||||
"""
|
"""
|
||||||
tracking_id = self.progress_tracker.start_tracking(
|
tracking_id = self.progress_tracker.start_tracking(
|
||||||
module="triplet_store",
|
module="triplet_store",
|
||||||
@@ -137,6 +167,62 @@ class BlazegraphStore:
|
|||||||
|
|
||||||
sparql_endpoint = self._get_sparql_endpoint()
|
sparql_endpoint = self._get_sparql_endpoint()
|
||||||
|
|
||||||
|
result_format = options.get("result_format")
|
||||||
|
if result_format is None:
|
||||||
|
result_format = "construct" if self._is_construct_query(query) else "bindings"
|
||||||
|
|
||||||
|
if result_format == "construct":
|
||||||
|
self.progress_tracker.update_tracking(
|
||||||
|
tracking_id, message="Sending CONSTRUCT query to Blazegraph endpoint..."
|
||||||
|
)
|
||||||
|
response = requests.post(
|
||||||
|
sparql_endpoint,
|
||||||
|
data={"query": query},
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "text/turtle",
|
||||||
|
},
|
||||||
|
timeout=self.timeout,
|
||||||
|
auth=(self.username, self.password)
|
||||||
|
if self.username and self.password
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
self.progress_tracker.update_tracking(
|
||||||
|
tracking_id, message="Parsing CONSTRUCT response as Turtle..."
|
||||||
|
)
|
||||||
|
graph = Graph()
|
||||||
|
try:
|
||||||
|
graph.parse(data=response.content, format="turtle")
|
||||||
|
except Exception as parse_error:
|
||||||
|
raise ProcessingError(
|
||||||
|
f"Failed to parse CONSTRUCT response as Turtle: {parse_error}"
|
||||||
|
) from parse_error
|
||||||
|
|
||||||
|
triples = [(str(s), str(p), str(o)) for s, p, o in graph]
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"success": True,
|
||||||
|
"bindings": [],
|
||||||
|
"variables": [],
|
||||||
|
"triples": triples,
|
||||||
|
"metadata": {
|
||||||
|
"query": query,
|
||||||
|
"endpoint": sparql_endpoint,
|
||||||
|
"result_format": "construct",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.progress_tracker.stop_tracking(
|
||||||
|
tracking_id,
|
||||||
|
status="completed",
|
||||||
|
message=f"CONSTRUCT query executed: {len(triples)} triples",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Non-CONSTRUCT path — unchanged from prior behavior.
|
||||||
self.progress_tracker.update_tracking(
|
self.progress_tracker.update_tracking(
|
||||||
tracking_id, message="Sending query to Blazegraph endpoint..."
|
tracking_id, message="Sending query to Blazegraph endpoint..."
|
||||||
)
|
)
|
||||||
@@ -307,31 +393,12 @@ class BlazegraphStore:
|
|||||||
- Known prefixed names: ``xsd:integer``, ``rdf:langString``, etc.
|
- Known prefixed names: ``xsd:integer``, ``rdf:langString``, etc.
|
||||||
|
|
||||||
Raises ValueError for anything else.
|
Raises ValueError for anything else.
|
||||||
|
|
||||||
|
Delegates to the shared sparql_escaping.resolve_datatype_iri so this
|
||||||
|
logic has one canonical implementation shared with the CONSTRUCT
|
||||||
|
template renderer (semantica/triplet_store/construct_templates.py).
|
||||||
"""
|
"""
|
||||||
datatype = str(datatype)
|
return sparql_escaping.resolve_datatype_iri(datatype)
|
||||||
|
|
||||||
# Already angle-bracketed — validate the inner IRI contains no whitespace
|
|
||||||
if datatype.startswith("<") and datatype.endswith(">"):
|
|
||||||
inner = datatype[1:-1]
|
|
||||||
if not inner or re.search(r"[\s<>\"{}|\\^`]", inner):
|
|
||||||
raise ValueError(f"Invalid datatype IRI: {datatype!r}")
|
|
||||||
return datatype
|
|
||||||
|
|
||||||
# Full absolute IRI without brackets
|
|
||||||
parsed = urlparse(datatype)
|
|
||||||
if parsed.scheme in {"http", "https", "urn"} and not re.search(r"[\s<>\"{}|\\^`]", datatype):
|
|
||||||
return f"<{datatype}>"
|
|
||||||
|
|
||||||
# Prefixed form — expand known prefixes only
|
|
||||||
if ":" in datatype:
|
|
||||||
prefix, local = datatype.split(":", 1)
|
|
||||||
if prefix in self._KNOWN_PREFIXES and re.match(r"^[A-Za-z0-9_\-\.]+$", local):
|
|
||||||
return f"<{self._KNOWN_PREFIXES[prefix]}{local}>"
|
|
||||||
|
|
||||||
raise ValueError(
|
|
||||||
f"Unsupported datatype {datatype!r}: use a full IRI (http/https/urn), "
|
|
||||||
f"an angle-bracketed IRI, or a known prefix (xsd/rdf/rdfs/owl/skos)."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _is_uri_value(self, value: str) -> bool:
|
def _is_uri_value(self, value: str) -> bool:
|
||||||
"""Detect if a value should be serialized as an IRI."""
|
"""Detect if a value should be serialized as an IRI."""
|
||||||
@@ -346,15 +413,13 @@ class BlazegraphStore:
|
|||||||
return not re.search(r"\s", value)
|
return not re.search(r"\s", value)
|
||||||
|
|
||||||
def _escape_literal(self, value: str) -> str:
|
def _escape_literal(self, value: str) -> str:
|
||||||
"""Escape string literal for SPARQL."""
|
"""Escape string literal for SPARQL.
|
||||||
return (
|
|
||||||
str(value)
|
Delegates to the shared sparql_escaping.escape_literal so this logic
|
||||||
.replace("\\", "\\\\")
|
has one canonical implementation shared with the CONSTRUCT template
|
||||||
.replace("\"", "\\\"")
|
renderer (semantica/triplet_store/construct_templates.py).
|
||||||
.replace("\n", "\\n")
|
"""
|
||||||
.replace("\r", "\\r")
|
return sparql_escaping.escape_literal(value)
|
||||||
.replace("\t", "\\t")
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_triplet(self, triplet: Triplet, **options) -> Dict[str, Any]:
|
def add_triplet(self, triplet: Triplet, **options) -> Dict[str, Any]:
|
||||||
"""Add single triplet."""
|
"""Add single triplet."""
|
||||||
|
|||||||
@@ -0,0 +1,723 @@
|
|||||||
|
"""
|
||||||
|
SPARQL CONSTRUCT Query Templates (Blazegraph-only)
|
||||||
|
|
||||||
|
This module provides parameterized SPARQL CONSTRUCT query templates: a
|
||||||
|
`ConstructTemplate` defines a reusable CONSTRUCT query body with `{{param}}`
|
||||||
|
placeholders, `ConstructTemplateRegistry` stores/retrieves templates by name,
|
||||||
|
and `render_construct_template` safely substitutes parameter values into a
|
||||||
|
validated, injection-safe SPARQL string.
|
||||||
|
|
||||||
|
This module is intentionally Blazegraph-only. `execute_construct_template`
|
||||||
|
renders a template, executes it via `store_backend.execute_sparql(...,
|
||||||
|
result_format="construct")` (the Blazegraph CONSTRUCT-aware extension),
|
||||||
|
converts the parsed RDF triples into `Triplet` objects, and persists them via
|
||||||
|
`store_backend.add_triplets`.
|
||||||
|
|
||||||
|
All literal escaping, URI allowlist validation, and datatype-IRI resolution
|
||||||
|
is delegated to the shared `semantica.triplet_store.sparql_escaping` module —
|
||||||
|
no escaping/validation logic is reimplemented here.
|
||||||
|
|
||||||
|
Main Classes:
|
||||||
|
- ParameterDescriptor: Declares one template parameter's name/type/validation.
|
||||||
|
- ConstructTemplate: Named, reusable CONSTRUCT query definition.
|
||||||
|
- ConstructTemplateRegistry: Stores and retrieves ConstructTemplate instances.
|
||||||
|
|
||||||
|
Main Functions:
|
||||||
|
- render_construct_template: Safely render a ConstructTemplate into SPARQL.
|
||||||
|
- execute_construct_template: Render, execute, parse, and persist in one call.
|
||||||
|
|
||||||
|
Author: Semantica Contributors
|
||||||
|
License: MIT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||||
|
|
||||||
|
from ..semantic_extract.triplet_extractor import Triplet
|
||||||
|
from ..utils.exceptions import ProcessingError, ValidationError
|
||||||
|
from ..utils.logging import get_logger
|
||||||
|
from .sparql_escaping import escape_literal, resolve_datatype_iri, validate_uri
|
||||||
|
|
||||||
|
ParameterKind = Literal["uri", "literal", "typed-literal"]
|
||||||
|
|
||||||
|
# XSD local names (case-insensitive) that render as unquoted numeric/boolean
|
||||||
|
# literals rather than quoted "<value>"^^<iri> literals. Matched against the
|
||||||
|
# local name of the *resolved* datatype IRI so this works whether the
|
||||||
|
# descriptor's datatype was given as a prefixed name (xsd:integer), a full
|
||||||
|
# IRI, or a bracketed IRI.
|
||||||
|
_INTEGER_LOCAL_NAMES = frozenset({"integer", "int", "long", "short"})
|
||||||
|
_DECIMAL_LOCAL_NAMES = frozenset({"decimal", "double", "float"})
|
||||||
|
_BOOLEAN_LOCAL_NAMES = frozenset({"boolean"})
|
||||||
|
_NUMERIC_UNQUOTED_LOCAL_NAMES = _INTEGER_LOCAL_NAMES | _DECIMAL_LOCAL_NAMES | _BOOLEAN_LOCAL_NAMES
|
||||||
|
|
||||||
|
_CONSTRUCT_KEYWORD_RE = re.compile(r"\bCONSTRUCT\b", re.IGNORECASE)
|
||||||
|
_WHERE_KEYWORD_RE = re.compile(r"\bWHERE\b", re.IGNORECASE)
|
||||||
|
_PLACEHOLDER_RE = re.compile(r"\{\{|\}\}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParameterDescriptor:
|
||||||
|
"""Describes one substitution parameter accepted by a ConstructTemplate."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
"""Placeholder name as it appears in the query body, e.g. "subject" for {{subject}}."""
|
||||||
|
|
||||||
|
type: ParameterKind = "literal"
|
||||||
|
"""One of "uri" | "literal" | "typed-literal"."""
|
||||||
|
|
||||||
|
required: bool = True
|
||||||
|
"""If True and no value/default is supplied at render time, render raises ValidationError."""
|
||||||
|
|
||||||
|
default: Optional[Any] = None
|
||||||
|
"""Used when the caller omits this parameter and required=False."""
|
||||||
|
|
||||||
|
datatype: Optional[str] = None
|
||||||
|
"""Only meaningful when type == "typed-literal". An XSD datatype token accepted by
|
||||||
|
the shared resolve_datatype_iri, e.g. "xsd:integer", "xsd:dateTime", or a full IRI.
|
||||||
|
Required when type == "typed-literal"; render_construct_template (and
|
||||||
|
ConstructTemplateRegistry.register) raise ValidationError if type == "typed-literal"
|
||||||
|
and datatype is None."""
|
||||||
|
|
||||||
|
language: Optional[str] = None
|
||||||
|
"""Only meaningful when type == "literal". RFC 5646 language tag, e.g. "en"."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConstructTemplate:
|
||||||
|
"""Parameterized SPARQL CONSTRUCT query template."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
"""Unique registry key, e.g. "person_to_foaf"."""
|
||||||
|
|
||||||
|
description: str
|
||||||
|
"""Human-readable summary, shown by list()/get_template_info()."""
|
||||||
|
|
||||||
|
construct_query: str
|
||||||
|
"""Full CONSTRUCT query body containing {{param}} placeholders, e.g.:
|
||||||
|
"CONSTRUCT { <{{subject}}> foaf:name {{name}} } WHERE { ... }"
|
||||||
|
Must contain the CONSTRUCT keyword — enforced at register() time, not at
|
||||||
|
dataclass construction time."""
|
||||||
|
|
||||||
|
parameters: List[ParameterDescriptor] = field(default_factory=list)
|
||||||
|
"""Ordered list of accepted parameters. Order has no runtime meaning, only used for
|
||||||
|
documentation / get_template_info() output."""
|
||||||
|
|
||||||
|
target_graph: Optional[str] = None
|
||||||
|
"""Optional default named-graph IRI used when render_construct_template's/
|
||||||
|
execute_construct_template's target_graph argument is not supplied. This value,
|
||||||
|
like any caller-supplied target_graph, is ALWAYS passed through the same
|
||||||
|
validate_uri/escape path as a "uri"-typed parameter before being interpolated —
|
||||||
|
never through a raw f-string."""
|
||||||
|
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
"""Free-form, mirrors PipelineTemplate.metadata (e.g. {"category": "rdf_mapping"})."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConstructTemplateRegistry:
|
||||||
|
"""
|
||||||
|
CONSTRUCT template management system (Blazegraph-only).
|
||||||
|
|
||||||
|
Method shape mirrors PipelineTemplateManager:
|
||||||
|
PipelineTemplateManager.register_template(template) -> None
|
||||||
|
PipelineTemplateManager.get_template(name) -> Optional[PipelineTemplate]
|
||||||
|
PipelineTemplateManager.list_templates(category=None) -> List[str]
|
||||||
|
|
||||||
|
Unlike PipelineTemplateManager.register_template (which silently overwrites
|
||||||
|
by name), this registry rejects duplicate names with ValidationError —
|
||||||
|
CONSTRUCT queries execute against real triple stores, so silent overwrite
|
||||||
|
is a correctness hazard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize template registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Configuration dictionary.
|
||||||
|
**kwargs: Additional configuration options.
|
||||||
|
"""
|
||||||
|
self.logger = get_logger("construct_template_registry")
|
||||||
|
self.config = config or {}
|
||||||
|
self.config.update(kwargs)
|
||||||
|
self.templates: Dict[str, ConstructTemplate] = {}
|
||||||
|
|
||||||
|
def register(self, template: ConstructTemplate) -> None:
|
||||||
|
"""
|
||||||
|
Register a CONSTRUCT template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: ConstructTemplate to register.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: if template.name is already registered,
|
||||||
|
construct_query does not contain the CONSTRUCT keyword, or any
|
||||||
|
ParameterDescriptor with type == "typed-literal" is missing
|
||||||
|
datatype. Validation happens before any mutation — a failed
|
||||||
|
register() call never partially overwrites the registry.
|
||||||
|
"""
|
||||||
|
if template.name in self.templates:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Template already registered: {template.name!r}. "
|
||||||
|
f"Remove it first via remove() if you intend to replace it."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _CONSTRUCT_KEYWORD_RE.search(template.construct_query):
|
||||||
|
raise ValidationError(
|
||||||
|
f"Template {template.name!r}: construct_query does not contain "
|
||||||
|
f"the CONSTRUCT keyword."
|
||||||
|
)
|
||||||
|
|
||||||
|
for descriptor in template.parameters:
|
||||||
|
if descriptor.type == "typed-literal" and descriptor.datatype is None:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Template {template.name!r}: parameter {descriptor.name!r} has "
|
||||||
|
f"type='typed-literal' but no datatype declared."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.templates[template.name] = template
|
||||||
|
self.logger.info(f"Registered CONSTRUCT template: {template.name}")
|
||||||
|
|
||||||
|
def get(self, name: str) -> Optional[ConstructTemplate]:
|
||||||
|
"""
|
||||||
|
Get template by name.
|
||||||
|
|
||||||
|
Mirrors PipelineTemplateManager.get_template(template_name) -> Optional[PipelineTemplate].
|
||||||
|
"""
|
||||||
|
return self.templates.get(name)
|
||||||
|
|
||||||
|
def list(self, category: Optional[str] = None) -> List[str]:
|
||||||
|
"""
|
||||||
|
List registered template names, optionally filtered by metadata["category"].
|
||||||
|
|
||||||
|
Mirrors PipelineTemplateManager.list_templates(category=None) -> List[str].
|
||||||
|
"""
|
||||||
|
if category:
|
||||||
|
return [
|
||||||
|
name
|
||||||
|
for name, template in self.templates.items()
|
||||||
|
if template.metadata.get("category") == category
|
||||||
|
]
|
||||||
|
return list(self.templates.keys())
|
||||||
|
|
||||||
|
def remove(self, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Remove a template by name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a template was removed, False if name was not registered.
|
||||||
|
"""
|
||||||
|
if name in self.templates:
|
||||||
|
del self.templates[name]
|
||||||
|
self.logger.info(f"Removed CONSTRUCT template: {name}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- PipelineTemplateManager-name aliases, for call-site consistency ---
|
||||||
|
|
||||||
|
def register_template(self, template: ConstructTemplate) -> None:
|
||||||
|
"""Alias for register(), matching PipelineTemplateManager.register_template."""
|
||||||
|
self.register(template)
|
||||||
|
|
||||||
|
def get_template(self, template_name: str) -> Optional[ConstructTemplate]:
|
||||||
|
"""Alias for get(), matching PipelineTemplateManager.get_template."""
|
||||||
|
return self.get(template_name)
|
||||||
|
|
||||||
|
def list_templates(self, category: Optional[str] = None) -> List[str]:
|
||||||
|
"""Alias for list(), matching PipelineTemplateManager.list_templates."""
|
||||||
|
return self.list(category)
|
||||||
|
|
||||||
|
def get_template_info(self, template_name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get template information.
|
||||||
|
|
||||||
|
Mirrors PipelineTemplateManager.get_template_info.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"name", "description", "parameter_count", "target_graph", "metadata"}
|
||||||
|
or None if template_name is not registered.
|
||||||
|
"""
|
||||||
|
template = self.get(template_name)
|
||||||
|
if not template:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": template.name,
|
||||||
|
"description": template.description,
|
||||||
|
"parameter_count": len(template.parameters),
|
||||||
|
"target_graph": template.target_graph,
|
||||||
|
"metadata": template.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# render_construct_template and its internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _local_name_of_datatype_iri(datatype_iri: str) -> str:
|
||||||
|
"""Extract the lower-cased local name from a resolved (bracketed) datatype IRI."""
|
||||||
|
inner = datatype_iri[1:-1] if datatype_iri.startswith("<") and datatype_iri.endswith(">") else datatype_iri
|
||||||
|
if "#" in inner:
|
||||||
|
return inner.rsplit("#", 1)[1].lower()
|
||||||
|
if "/" in inner:
|
||||||
|
return inner.rsplit("/", 1)[1].lower()
|
||||||
|
return inner.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_numeric_literal(value: Any, local_name: str, descriptor_name: str) -> str:
|
||||||
|
"""
|
||||||
|
Render an unquoted numeric/boolean literal for a "typed-literal" parameter
|
||||||
|
whose resolved datatype local name is in _NUMERIC_UNQUOTED_LOCAL_NAMES.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: if value cannot be coerced to the declared datatype.
|
||||||
|
"""
|
||||||
|
if local_name in _INTEGER_LOCAL_NAMES:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValidationError(
|
||||||
|
f"Parameter {descriptor_name!r}: expected an integer value for "
|
||||||
|
f"datatype local name {local_name!r}, got boolean {value!r}."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return str(int(str(value)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValidationError(
|
||||||
|
f"Parameter {descriptor_name!r}: value {value!r} is not a valid "
|
||||||
|
f"integer for its declared XSD datatype."
|
||||||
|
)
|
||||||
|
|
||||||
|
if local_name in _DECIMAL_LOCAL_NAMES:
|
||||||
|
try:
|
||||||
|
return str(float(str(value)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValidationError(
|
||||||
|
f"Parameter {descriptor_name!r}: value {value!r} is not a valid "
|
||||||
|
f"decimal/double/float for its declared XSD datatype."
|
||||||
|
)
|
||||||
|
|
||||||
|
if local_name in _BOOLEAN_LOCAL_NAMES:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
normalized = str(value).strip().lower()
|
||||||
|
if normalized in ("true", "1"):
|
||||||
|
return "true"
|
||||||
|
if normalized in ("false", "0"):
|
||||||
|
return "false"
|
||||||
|
raise ValidationError(
|
||||||
|
f"Parameter {descriptor_name!r}: value {value!r} is not a valid "
|
||||||
|
f"boolean for its declared XSD datatype."
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ValidationError(
|
||||||
|
f"Parameter {descriptor_name!r}: datatype local name {local_name!r} is not "
|
||||||
|
f"a recognized unquoted-numeric XSD datatype."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_matching_brace(text: str, open_index: int) -> int:
|
||||||
|
"""
|
||||||
|
Given the index of an opening '{' in text, return the index of its matching '}'.
|
||||||
|
|
||||||
|
Skips over double-quoted string literal content while scanning, so a '{'
|
||||||
|
or '}' character that appears *inside* a rendered literal value (e.g. a
|
||||||
|
"typed-literal"/"literal" parameter whose value contains a brace
|
||||||
|
character — braces are not among the characters escape_literal escapes,
|
||||||
|
since SPARQL/Turtle string literals don't require it) does not corrupt
|
||||||
|
the brace-depth count. Quote-escaping is assumed to follow
|
||||||
|
escape_literal's convention (\\\\ and \\" are the only two-character
|
||||||
|
escapes that can produce a literal backslash or double-quote), so an
|
||||||
|
unescaped '"' reliably toggles in/out of string-literal content.
|
||||||
|
"""
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
i = open_index
|
||||||
|
while i < len(text):
|
||||||
|
ch = text[i]
|
||||||
|
if in_string:
|
||||||
|
if ch == "\\":
|
||||||
|
# Skip the escaped character (e.g. \" or \\) without
|
||||||
|
# inspecting it, matching escape_literal's escaping scheme.
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if ch == '"':
|
||||||
|
in_string = False
|
||||||
|
else:
|
||||||
|
if ch == '"':
|
||||||
|
in_string = True
|
||||||
|
elif ch == "{":
|
||||||
|
depth += 1
|
||||||
|
elif ch == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return i
|
||||||
|
i += 1
|
||||||
|
raise ValidationError("Unbalanced braces in construct_query: no matching '}' found.")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_construct_query(query_body: str) -> Tuple[str, str, str]:
|
||||||
|
"""
|
||||||
|
Split a fully-substituted CONSTRUCT query into (preamble, construct_clause,
|
||||||
|
where_body), so the WHERE body can be re-wrapped in a GRAPH clause for
|
||||||
|
target_graph support.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query_body: Fully-substituted query string (all {{name}} tokens
|
||||||
|
already replaced).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
preamble: Everything before the CONSTRUCT keyword (e.g. PREFIX
|
||||||
|
declarations), stripped of leading/trailing whitespace. Preserved
|
||||||
|
verbatim so PREFIX declarations are not silently dropped when
|
||||||
|
target_graph wrapping rewrites the CONSTRUCT/WHERE structure.
|
||||||
|
construct_clause: The raw "{ ... }" template graph pattern immediately
|
||||||
|
following CONSTRUCT, braces included.
|
||||||
|
where_body: The raw text *inside* the "{ ... }" following WHERE,
|
||||||
|
braces excluded.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: if the CONSTRUCT/WHERE structure cannot be located.
|
||||||
|
"""
|
||||||
|
construct_match = _CONSTRUCT_KEYWORD_RE.search(query_body)
|
||||||
|
if not construct_match:
|
||||||
|
raise ValidationError("construct_query does not contain the CONSTRUCT keyword.")
|
||||||
|
preamble = query_body[: construct_match.start()].strip()
|
||||||
|
|
||||||
|
construct_open = query_body.find("{", construct_match.end())
|
||||||
|
if construct_open == -1:
|
||||||
|
raise ValidationError("construct_query is missing '{' after the CONSTRUCT keyword.")
|
||||||
|
construct_close = _find_matching_brace(query_body, construct_open)
|
||||||
|
construct_clause = query_body[construct_open : construct_close + 1]
|
||||||
|
|
||||||
|
where_match = _WHERE_KEYWORD_RE.search(query_body, construct_close + 1)
|
||||||
|
if not where_match:
|
||||||
|
raise ValidationError("construct_query is missing a WHERE clause.")
|
||||||
|
where_open = query_body.find("{", where_match.end())
|
||||||
|
if where_open == -1:
|
||||||
|
raise ValidationError("construct_query is missing '{' after the WHERE keyword.")
|
||||||
|
where_close = _find_matching_brace(query_body, where_open)
|
||||||
|
where_body = query_body[where_open + 1 : where_close]
|
||||||
|
|
||||||
|
return preamble, construct_clause, where_body
|
||||||
|
|
||||||
|
|
||||||
|
def render_construct_template(
|
||||||
|
template: ConstructTemplate,
|
||||||
|
params: Dict[str, Any],
|
||||||
|
target_graph: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Render a ConstructTemplate's construct_query into a safe, executable SPARQL
|
||||||
|
CONSTRUCT query string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: The ConstructTemplate to render.
|
||||||
|
params: Values for each {{name}} placeholder, keyed by ParameterDescriptor.name.
|
||||||
|
Values for missing optional parameters fall back to ParameterDescriptor.default.
|
||||||
|
target_graph: Named graph IRI to wrap the CONSTRUCT query in (via a GRAPH
|
||||||
|
clause around the WHERE body). If None, falls back to
|
||||||
|
template.target_graph. If both are None, no graph wrapping is applied.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Fully-substituted SPARQL CONSTRUCT query string, safe to pass directly to
|
||||||
|
BlazegraphStore.execute_sparql.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: on any of:
|
||||||
|
- a required ParameterDescriptor has no value in params and no default
|
||||||
|
- a "uri"-typed parameter value fails validate_uri
|
||||||
|
- a "typed-literal"-typed parameter is missing template-declared datatype
|
||||||
|
- a "typed-literal"-typed parameter value cannot be coerced to that datatype
|
||||||
|
- target_graph (explicit arg or template.target_graph) fails validate_uri
|
||||||
|
- construct_query references a {{placeholder}} with no matching
|
||||||
|
ParameterDescriptor (detected as an unresolved placeholder after
|
||||||
|
substitution)
|
||||||
|
"""
|
||||||
|
# Step 1: Resolve effective parameter values (params override, then default).
|
||||||
|
resolved: Dict[str, Any] = {}
|
||||||
|
for descriptor in template.parameters:
|
||||||
|
if descriptor.name in params:
|
||||||
|
resolved[descriptor.name] = params[descriptor.name]
|
||||||
|
elif not descriptor.required:
|
||||||
|
resolved[descriptor.name] = descriptor.default
|
||||||
|
else:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Missing required parameter: {descriptor.name!r} "
|
||||||
|
f"(template {template.name!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Render each parameter value according to its declared type.
|
||||||
|
rendered_values: Dict[str, str] = {}
|
||||||
|
for descriptor in template.parameters:
|
||||||
|
value = resolved[descriptor.name]
|
||||||
|
|
||||||
|
if descriptor.type == "uri":
|
||||||
|
safe_uri = validate_uri(value)
|
||||||
|
rendered_values[descriptor.name] = f"<{safe_uri}>"
|
||||||
|
|
||||||
|
elif descriptor.type == "literal":
|
||||||
|
escaped = escape_literal(value)
|
||||||
|
if descriptor.language is not None:
|
||||||
|
rendered_values[descriptor.name] = f'"{escaped}"@{descriptor.language}'
|
||||||
|
else:
|
||||||
|
rendered_values[descriptor.name] = f'"{escaped}"'
|
||||||
|
|
||||||
|
elif descriptor.type == "typed-literal":
|
||||||
|
if descriptor.datatype is None:
|
||||||
|
raise ValidationError(
|
||||||
|
f"typed-literal parameter requires datatype: {descriptor.name!r} "
|
||||||
|
f"(template {template.name!r})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
datatype_iri = resolve_datatype_iri(descriptor.datatype)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Parameter {descriptor.name!r}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
local_name = _local_name_of_datatype_iri(datatype_iri)
|
||||||
|
if local_name in _NUMERIC_UNQUOTED_LOCAL_NAMES:
|
||||||
|
rendered_values[descriptor.name] = _render_numeric_literal(
|
||||||
|
value, local_name, descriptor.name
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
escaped = escape_literal(value)
|
||||||
|
rendered_values[descriptor.name] = f'"{escaped}"^^{datatype_iri}'
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Parameter {descriptor.name!r}: unknown parameter type {descriptor.type!r} "
|
||||||
|
f"(template {template.name!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Substitute {{name}} tokens in construct_query.
|
||||||
|
query_body = template.construct_query
|
||||||
|
for name, rendered in rendered_values.items():
|
||||||
|
query_body = query_body.replace("{{" + name + "}}", rendered)
|
||||||
|
|
||||||
|
if _PLACEHOLDER_RE.search(query_body):
|
||||||
|
raise ValidationError(
|
||||||
|
f"Unresolved placeholder(s) in construct_query for template "
|
||||||
|
f"{template.name!r}: query still contains '{{{{' / '}}}}' tokens "
|
||||||
|
f"with no matching ParameterDescriptor."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Resolve effective target_graph — SAME validate_uri path as any
|
||||||
|
# "uri" parameter. No raw f-string interpolation is used here.
|
||||||
|
effective_graph = target_graph if target_graph is not None else template.target_graph
|
||||||
|
if effective_graph is not None:
|
||||||
|
safe_graph_uri = validate_uri(effective_graph)
|
||||||
|
wrapped_graph_token = f"<{safe_graph_uri}>"
|
||||||
|
|
||||||
|
preamble, construct_clause, where_body = _split_construct_query(query_body)
|
||||||
|
graph_wrapped_query = (
|
||||||
|
f"CONSTRUCT {construct_clause} "
|
||||||
|
f"WHERE {{ GRAPH {wrapped_graph_token} {{ {where_body} }} }}"
|
||||||
|
)
|
||||||
|
rendered_query = f"{preamble}\n{graph_wrapped_query}" if preamble else graph_wrapped_query
|
||||||
|
else:
|
||||||
|
rendered_query = query_body
|
||||||
|
|
||||||
|
return rendered_query
|
||||||
|
|
||||||
|
|
||||||
|
def execute_construct_template(
|
||||||
|
template: ConstructTemplate,
|
||||||
|
params: Dict[str, Any],
|
||||||
|
store_backend: Any,
|
||||||
|
target_graph: Optional[str] = None,
|
||||||
|
**options: Any,
|
||||||
|
) -> List[Triplet]:
|
||||||
|
"""
|
||||||
|
Render, execute, parse, and persist a CONSTRUCT template in one call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: ConstructTemplate to execute.
|
||||||
|
params: Parameter values, forwarded to render_construct_template.
|
||||||
|
store_backend: A BlazegraphStore instance (Blazegraph-only; duck-typed
|
||||||
|
via hasattr(store_backend, "execute_sparql"), but this function
|
||||||
|
additionally requires store_backend to expose add_triplets — a
|
||||||
|
plain SPARQL-only backend without write support is rejected with
|
||||||
|
ProcessingError).
|
||||||
|
target_graph: Forwarded to render_construct_template; also used as
|
||||||
|
the `graph` option when persisting results via add_triplets so
|
||||||
|
constructed triples land in the same named graph they were
|
||||||
|
scoped to at query time.
|
||||||
|
**options: Forwarded to both store_backend.execute_sparql and
|
||||||
|
store_backend.add_triplets (e.g. timeout overrides).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The List[Triplet] that were constructed AND successfully persisted
|
||||||
|
via add_triplets. Order matches the order the store backend's
|
||||||
|
execute_sparql yielded triples in its "triples" key.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: propagated from render_construct_template.
|
||||||
|
ProcessingError: if store_backend lacks execute_sparql/add_triplets,
|
||||||
|
or if persistence via add_triplets does not report success.
|
||||||
|
|
||||||
|
Exception-propagation convention:
|
||||||
|
This function does not wrap or catch exceptions raised by
|
||||||
|
render_construct_template or store_backend.execute_sparql — each
|
||||||
|
sub-layer is responsible for raising its own correctly-typed
|
||||||
|
exception (ValidationError for rendering failures; ProcessingError
|
||||||
|
for execution failures, as BlazegraphStore.execute_sparql already
|
||||||
|
does internally for connection/request/Turtle-parse errors). Adding
|
||||||
|
a second wrapping layer here would only obscure the original error
|
||||||
|
with no new information. The one exception this function DOES raise
|
||||||
|
itself is the add_triplets write-failure case immediately below,
|
||||||
|
because add_triplets signals failure via a returned dict rather than
|
||||||
|
an exception, so there is no pre-existing typed exception to let
|
||||||
|
propagate.
|
||||||
|
"""
|
||||||
|
if not (hasattr(store_backend, "execute_sparql") and hasattr(store_backend, "add_triplets")):
|
||||||
|
raise ProcessingError(
|
||||||
|
"store_backend must support both execute_sparql and add_triplets "
|
||||||
|
"to use execute_construct_template."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 1: Render (raises ValidationError unchanged on any failure).
|
||||||
|
rendered_query = render_construct_template(template, params, target_graph)
|
||||||
|
|
||||||
|
# Step 2: Execute via Blazegraph's CONSTRUCT-aware path.
|
||||||
|
query_result = store_backend.execute_sparql(
|
||||||
|
rendered_query, result_format="construct", **options
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Convert parsed RDF triples to Triplet objects. store_backend is
|
||||||
|
# a BlazegraphStore instance, whose execute_sparql returns Dict[str, Any]
|
||||||
|
# with a "triples" key for CONSTRUCT queries (see BlazegraphStore.execute_sparql).
|
||||||
|
raw_triples = query_result.get("triples", [])
|
||||||
|
triplets: List[Triplet] = []
|
||||||
|
for s, p, o in raw_triples:
|
||||||
|
triplets.append(
|
||||||
|
Triplet(
|
||||||
|
subject=str(s),
|
||||||
|
predicate=str(p),
|
||||||
|
object=str(o),
|
||||||
|
# confidence=1.0 (explicit, matching Triplet's own default):
|
||||||
|
# a CONSTRUCT query is a deterministic graph transformation
|
||||||
|
# over already-persisted RDF/SPARQL-computed data, not a
|
||||||
|
# probabilistic extraction (unlike NER/LLM-based Triplet
|
||||||
|
# extraction, where confidence reflects genuine estimation
|
||||||
|
# uncertainty). There is no meaningful uncertainty to encode
|
||||||
|
# here, so full confidence is the correct value, not an
|
||||||
|
# accidental default.
|
||||||
|
confidence=1.0,
|
||||||
|
metadata={"source": "construct_template", "template": template.name},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Persist via add_triplets (same write path as any other bulk load).
|
||||||
|
effective_graph = target_graph if target_graph is not None else template.target_graph
|
||||||
|
write_result = store_backend.add_triplets(triplets, graph=effective_graph, **options)
|
||||||
|
|
||||||
|
if not write_result.get("success", False):
|
||||||
|
raise ProcessingError(
|
||||||
|
f"Failed to persist constructed triples for template "
|
||||||
|
f"{template.name!r}: {write_result}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return triplets
|
||||||
|
|
||||||
|
|
||||||
|
def construct_template_step_handler(data: Any, **options: Any) -> List[Triplet]:
|
||||||
|
"""
|
||||||
|
Pipeline step handler for the "construct_template" step type.
|
||||||
|
|
||||||
|
Resolves `store_backend` and `construct_template_registry` from execution
|
||||||
|
options, looks up the named template, and delegates to
|
||||||
|
execute_construct_template. Mirrors the exact resolution pattern used for
|
||||||
|
`triplet_store`/`version_manager` in
|
||||||
|
ExecutionEngine._execute_step's delta_mode handling: call-time options
|
||||||
|
first, engine config fallback second, ProcessingError if either is
|
||||||
|
missing.
|
||||||
|
|
||||||
|
Called by ExecutionEngine._execute_step as
|
||||||
|
`step.handler(data, **step.config, **options)` — no change to
|
||||||
|
PipelineStep or _execute_step is required, since step.config and
|
||||||
|
**options already flow through generically for any step type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Pipeline data flowing in (unused by this step type — a
|
||||||
|
construct_template step's output is independent of upstream
|
||||||
|
step data, exactly like other non-delta step types).
|
||||||
|
**options: The merged step.config + execution-time options dict, as
|
||||||
|
passed by ExecutionEngine._execute_step's
|
||||||
|
`step.handler(data, **step.config, **options)` call. Expected
|
||||||
|
keys:
|
||||||
|
- template_name: str — required, key into
|
||||||
|
construct_template_registry.
|
||||||
|
- params: Dict[str, Any] — required, forwarded to
|
||||||
|
render_construct_template.
|
||||||
|
- target_graph: Optional[str] — optional.
|
||||||
|
- store_backend: Any — resolved from options directly, or
|
||||||
|
from an "engine_config" dict passed alongside it (see
|
||||||
|
below); required.
|
||||||
|
- construct_template_registry: ConstructTemplateRegistry —
|
||||||
|
resolved the same way; required.
|
||||||
|
- engine_config: Optional[Dict[str, Any]] — fallback source
|
||||||
|
for store_backend/construct_template_registry when not
|
||||||
|
present directly in options, mirroring the *shape* of
|
||||||
|
self.config.get(...) in ExecutionEngine._execute_step's
|
||||||
|
delta_mode handling. One real difference from delta_mode:
|
||||||
|
delta_mode's fallback runs inside _execute_step itself (a
|
||||||
|
bound ExecutionEngine method with direct access to
|
||||||
|
self.config), whereas construct_template_step_handler is a
|
||||||
|
plain function with no such access — by design, this step
|
||||||
|
type requires no pre-handler interception in
|
||||||
|
_execute_step (see Requirement 7.5), so self.config is
|
||||||
|
simply never threaded to any step.handler call today. In
|
||||||
|
practice this means store_backend/construct_template_registry
|
||||||
|
should normally be passed as call-time options via
|
||||||
|
ExecutionEngine.execute_pipeline(pipeline, data,
|
||||||
|
store_backend=..., construct_template_registry=...),
|
||||||
|
which flow through untouched to this handler. The
|
||||||
|
engine_config parameter exists for callers who explicitly
|
||||||
|
forward their own ExecutionEngine(config=...) dict into
|
||||||
|
execute_pipeline's options (e.g. engine_config=engine.config)
|
||||||
|
and want the same two-tier resolution shape as delta_mode.
|
||||||
|
- step_name: Optional[str] — used only to name the failing
|
||||||
|
step in error messages; defaults to "construct_template"
|
||||||
|
if not supplied (design.md's own pseudocode references
|
||||||
|
step.name inside this handler, but the actual
|
||||||
|
step.handler(data, **step.config, **options) invocation
|
||||||
|
never passes step.name to a standalone handler function —
|
||||||
|
this default covers that gap without requiring any
|
||||||
|
ExecutionEngine/_execute_step change).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The List[Triplet] returned by execute_construct_template.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessingError: if store_backend or construct_template_registry
|
||||||
|
cannot be resolved from options (or its "engine_config" fallback).
|
||||||
|
ValidationError: if template_name is not registered in the resolved
|
||||||
|
construct_template_registry.
|
||||||
|
"""
|
||||||
|
step_name = options.get("step_name", "construct_template")
|
||||||
|
engine_config = options.get("engine_config") or {}
|
||||||
|
|
||||||
|
store_backend = options.get("store_backend") or engine_config.get("store_backend")
|
||||||
|
construct_template_registry = options.get(
|
||||||
|
"construct_template_registry"
|
||||||
|
) or engine_config.get("construct_template_registry")
|
||||||
|
|
||||||
|
if not store_backend or not construct_template_registry:
|
||||||
|
raise ProcessingError(
|
||||||
|
f"Step '{step_name}' requires 'store_backend' and "
|
||||||
|
f"'construct_template_registry' in execution options for "
|
||||||
|
f"construct_template processing."
|
||||||
|
)
|
||||||
|
|
||||||
|
template_name = options.get("template_name")
|
||||||
|
template = construct_template_registry.get(template_name)
|
||||||
|
if template is None:
|
||||||
|
raise ValidationError(f"Unknown construct template: {template_name!r}")
|
||||||
|
|
||||||
|
return execute_construct_template(
|
||||||
|
template=template,
|
||||||
|
params=options.get("params", {}),
|
||||||
|
store_backend=store_backend,
|
||||||
|
target_graph=options.get("target_graph"),
|
||||||
|
)
|
||||||
@@ -49,6 +49,12 @@ class QueryResult:
|
|||||||
variables: List[str]
|
variables: List[str]
|
||||||
execution_time: float = 0.0
|
execution_time: float = 0.0
|
||||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
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) string 3-tuple, taken directly from the store
|
||||||
|
backend's execute_sparql "triples" key (see BlazegraphStore.execute_sparql
|
||||||
|
CONSTRUCT path). Empty list for all SELECT/ASK/DESCRIBE queries and for
|
||||||
|
backends without CONSTRUCT support."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -183,6 +189,7 @@ class QueryEngine:
|
|||||||
bindings=result_data.get("bindings", []),
|
bindings=result_data.get("bindings", []),
|
||||||
variables=result_data.get("variables", []),
|
variables=result_data.get("variables", []),
|
||||||
execution_time=execution_time,
|
execution_time=execution_time,
|
||||||
|
triples=result_data.get("triples", []),
|
||||||
metadata={
|
metadata={
|
||||||
**result_data.get("metadata", {}),
|
**result_data.get("metadata", {}),
|
||||||
"optimized": optimized_query != prepared_query,
|
"optimized": optimized_query != prepared_query,
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Shared SPARQL literal-escaping and URI-validation primitives.
|
||||||
|
|
||||||
|
This module centralizes string-escaping, datatype-IRI resolution, and URI
|
||||||
|
allowlist validation logic that was previously duplicated (or would have
|
||||||
|
been duplicated) between ``BlazegraphStore`` and the CONSTRUCT template
|
||||||
|
renderer. ``BlazegraphStore._escape_literal`` and
|
||||||
|
``BlazegraphStore._resolve_datatype_iri`` delegate to ``escape_literal`` and
|
||||||
|
``resolve_datatype_iri`` here without any change in behavior; ``validate_uri``
|
||||||
|
is new and is used by ``render_construct_template`` for both ``"uri"``-typed
|
||||||
|
parameters and ``target_graph`` values.
|
||||||
|
|
||||||
|
Author: Semantica Contributors
|
||||||
|
License: MIT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import FrozenSet
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from ..utils.exceptions import ValidationError
|
||||||
|
|
||||||
|
# Allowed URI schemes for validate_uri's allowlist (Requirement 3.2/4.4).
|
||||||
|
_ALLOWED_URI_SCHEMES: FrozenSet[str] = frozenset({"http", "https", "urn"})
|
||||||
|
|
||||||
|
# Known prefix expansions for XSD and common RDF vocabularies.
|
||||||
|
# Identical table to BlazegraphStore._KNOWN_PREFIXES.
|
||||||
|
KNOWN_PREFIXES: dict = {
|
||||||
|
"xsd": "http://www.w3.org/2001/XMLSchema#",
|
||||||
|
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
||||||
|
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
|
||||||
|
"owl": "http://www.w3.org/2002/07/owl#",
|
||||||
|
"skos": "http://www.w3.org/2004/02/skos/core#",
|
||||||
|
}
|
||||||
|
|
||||||
|
# RFC 5646 language tag: primary subtag optionally followed by '-' + subtags.
|
||||||
|
# Identical pattern to BlazegraphStore._LANG_TAG_RE.
|
||||||
|
LANG_TAG_RE = re.compile(r"^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$")
|
||||||
|
|
||||||
|
# Disallowed-character check shared by validate_uri and resolve_datatype_iri,
|
||||||
|
# identical to the character class used throughout BlazegraphStore.
|
||||||
|
_DISALLOWED_URI_CHARS_RE = re.compile(r"[\s<>\"{}|\\^`]")
|
||||||
|
|
||||||
|
|
||||||
|
def escape_literal(value: str) -> str:
|
||||||
|
"""
|
||||||
|
Escape a string literal for safe inclusion inside SPARQL double quotes.
|
||||||
|
|
||||||
|
This is a byte-for-byte copy of BlazegraphStore._escape_literal's
|
||||||
|
transformation:
|
||||||
|
\\ -> \\\\ , " -> \\" , \\n -> \\n , \\r -> \\r , \\t -> \\t
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Raw literal value (converted via str() first, matching the
|
||||||
|
original method's behavior of accepting non-str inputs).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Escaped string safe to place inside a SPARQL/Turtle double-quoted
|
||||||
|
literal.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
str(value)
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
.replace("\t", "\\t")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_datatype_iri(datatype: str) -> str:
|
||||||
|
"""
|
||||||
|
Expand a datatype string to a validated SPARQL IRI token.
|
||||||
|
|
||||||
|
This is a byte-for-byte copy of BlazegraphStore._resolve_datatype_iri's
|
||||||
|
logic and exception behavior (raises ValueError, not ValidationError, to
|
||||||
|
match the original method's existing contract).
|
||||||
|
|
||||||
|
Accepts:
|
||||||
|
- Already-wrapped IRIs: ``<http://...>``
|
||||||
|
- Full IRIs: ``http://...`` / ``https://...`` / ``urn:...``
|
||||||
|
- Known prefixed names: ``xsd:integer``, ``rdf:langString``, etc.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: for anything else, or for malformed/unsafe IRIs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An angle-bracketed IRI token, e.g. ``<http://www.w3.org/2001/XMLSchema#integer>``.
|
||||||
|
"""
|
||||||
|
datatype = str(datatype)
|
||||||
|
|
||||||
|
# Already angle-bracketed — validate the inner IRI contains no whitespace
|
||||||
|
if datatype.startswith("<") and datatype.endswith(">"):
|
||||||
|
inner = datatype[1:-1]
|
||||||
|
if not inner or _DISALLOWED_URI_CHARS_RE.search(inner):
|
||||||
|
raise ValueError(f"Invalid datatype IRI: {datatype!r}")
|
||||||
|
return datatype
|
||||||
|
|
||||||
|
# Full absolute IRI without brackets
|
||||||
|
parsed = urlparse(datatype)
|
||||||
|
if parsed.scheme in {"http", "https", "urn"} and not _DISALLOWED_URI_CHARS_RE.search(datatype):
|
||||||
|
return f"<{datatype}>"
|
||||||
|
|
||||||
|
# Prefixed form — expand known prefixes only
|
||||||
|
if ":" in datatype:
|
||||||
|
prefix, local = datatype.split(":", 1)
|
||||||
|
if prefix in KNOWN_PREFIXES and re.match(r"^[A-Za-z0-9_\-\.]+$", local):
|
||||||
|
return f"<{KNOWN_PREFIXES[prefix]}{local}>"
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported datatype {datatype!r}: use a full IRI (http/https/urn), "
|
||||||
|
f"an angle-bracketed IRI, or a known prefix (xsd/rdf/rdfs/owl/skos)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_uri(
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
allowed_schemes: FrozenSet[str] = _ALLOWED_URI_SCHEMES,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Validate that `value` is a safe absolute IRI using urllib.parse, and
|
||||||
|
return it unchanged (no bracket-wrapping — callers wrap with <...> at
|
||||||
|
render time).
|
||||||
|
|
||||||
|
Uses urllib.parse.urlparse(value):
|
||||||
|
- scheme must be in allowed_schemes (default {"http", "https", "urn"})
|
||||||
|
- the value must not contain whitespace or any of
|
||||||
|
< > " { } | \\ ^ ` characters (same disallowed-character set used by
|
||||||
|
resolve_datatype_iri / BlazegraphStore's existing IRI checks)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Candidate URI/IRI string.
|
||||||
|
allowed_schemes: Set of permitted URI schemes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated `value`, unchanged.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: if value is empty, not a string, has a scheme not in
|
||||||
|
allowed_schemes, or contains a disallowed character.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ValidationError(f"Invalid URI: value must be a non-empty string, got {value!r}")
|
||||||
|
|
||||||
|
if _DISALLOWED_URI_CHARS_RE.search(value):
|
||||||
|
raise ValidationError(
|
||||||
|
f"Invalid URI {value!r}: contains disallowed character(s) "
|
||||||
|
f"(whitespace or one of < > \" {{ }} | \\ ^ `)"
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = urlparse(value)
|
||||||
|
if parsed.scheme not in allowed_schemes:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Invalid URI {value!r}: scheme {parsed.scheme!r} is not allowed "
|
||||||
|
f"(allowed: {sorted(allowed_schemes)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
if PROJECT_ROOT not in sys.path:
|
if PROJECT_ROOT not in sys.path:
|
||||||
@@ -9,6 +9,7 @@ if PROJECT_ROOT not in sys.path:
|
|||||||
|
|
||||||
from semantica.semantic_extract.triplet_extractor import Triplet
|
from semantica.semantic_extract.triplet_extractor import Triplet
|
||||||
from semantica.triplet_store.blazegraph_store import BlazegraphStore
|
from semantica.triplet_store.blazegraph_store import BlazegraphStore
|
||||||
|
from semantica.utils.exceptions import ProcessingError
|
||||||
|
|
||||||
|
|
||||||
class TestBlazegraphStoreSerialization(unittest.TestCase):
|
class TestBlazegraphStoreSerialization(unittest.TestCase):
|
||||||
@@ -221,5 +222,315 @@ class TestBlazegraphStoreSerialization(unittest.TestCase):
|
|||||||
self.assertEqual(obj, "\"Colour\"@en-GB")
|
self.assertEqual(obj, "\"Colour\"@en-GB")
|
||||||
|
|
||||||
|
|
||||||
|
from semantica.triplet_store import sparql_escaping
|
||||||
|
|
||||||
|
|
||||||
|
class TestSparqlEscapingExtractionParity(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
Regression tests proving BlazegraphStore._escape_literal /
|
||||||
|
BlazegraphStore._resolve_datatype_iri are byte-for-byte identical in
|
||||||
|
behavior to the extracted sparql_escaping.escape_literal /
|
||||||
|
sparql_escaping.resolve_datatype_iri functions, across every branch of
|
||||||
|
both original methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --- escape_literal: every special character + combinations + plain text ---
|
||||||
|
|
||||||
|
ESCAPE_LITERAL_CASES = [
|
||||||
|
"", # empty string
|
||||||
|
"plain text, no special chars",
|
||||||
|
"back\\slash", # backslash
|
||||||
|
'embedded "double" quote', # double quote
|
||||||
|
"line1\nline2", # newline
|
||||||
|
"line1\rline2", # carriage return
|
||||||
|
"tab\there", # tab
|
||||||
|
"\\\"\n\r\t", # all five special characters combined
|
||||||
|
'mix \\ and " and \n and \r and \t together',
|
||||||
|
42, # non-str input (both methods call str(value) first)
|
||||||
|
None,
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_escape_literal_matches_blazegraph_store_for_every_branch(self):
|
||||||
|
with patch.object(BlazegraphStore, "_connect", autospec=True):
|
||||||
|
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
|
||||||
|
for value in self.ESCAPE_LITERAL_CASES:
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.assertEqual(
|
||||||
|
store._escape_literal(value),
|
||||||
|
sparql_escaping.escape_literal(value),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- resolve_datatype_iri: every branch (bracketed, full IRI, prefixed,
|
||||||
|
# invalid bracketed, invalid full IRI-with-whitespace, unknown prefix) ---
|
||||||
|
|
||||||
|
RESOLVE_DATATYPE_IRI_VALID_CASES = [
|
||||||
|
"<http://www.w3.org/2001/XMLSchema#integer>", # already bracketed
|
||||||
|
"http://www.w3.org/2001/XMLSchema#integer", # full IRI, no brackets
|
||||||
|
"https://example.org/type", # https scheme
|
||||||
|
"urn:isbn:0451450523", # urn scheme
|
||||||
|
"xsd:integer", # known prefix
|
||||||
|
"rdf:langString", # known prefix
|
||||||
|
"rdfs:label", # known prefix
|
||||||
|
"owl:Thing", # known prefix
|
||||||
|
"skos:Concept", # known prefix
|
||||||
|
]
|
||||||
|
|
||||||
|
RESOLVE_DATATYPE_IRI_ERROR_CASES = [
|
||||||
|
"<>", # empty bracketed IRI
|
||||||
|
"<http://example.org/type with space>", # bracketed IRI with whitespace
|
||||||
|
"<http://example.org/type<injected>", # bracketed IRI with disallowed char
|
||||||
|
"http://example.org/type CLEAR ALL", # full IRI with whitespace
|
||||||
|
"myns:customType", # unknown prefix
|
||||||
|
"not_a_uri_no_colon", # no scheme, no colon-prefixed form matches
|
||||||
|
"javascript:alert(1)", # disallowed scheme, not http/https/urn, no known prefix match
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_resolve_datatype_iri_matches_blazegraph_store_for_valid_cases(self):
|
||||||
|
with patch.object(BlazegraphStore, "_connect", autospec=True):
|
||||||
|
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
|
||||||
|
for datatype in self.RESOLVE_DATATYPE_IRI_VALID_CASES:
|
||||||
|
with self.subTest(datatype=datatype):
|
||||||
|
self.assertEqual(
|
||||||
|
store._resolve_datatype_iri(datatype),
|
||||||
|
sparql_escaping.resolve_datatype_iri(datatype),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolve_datatype_iri_matches_blazegraph_store_for_error_cases(self):
|
||||||
|
with patch.object(BlazegraphStore, "_connect", autospec=True):
|
||||||
|
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
|
||||||
|
for datatype in self.RESOLVE_DATATYPE_IRI_ERROR_CASES:
|
||||||
|
with self.subTest(datatype=datatype):
|
||||||
|
store_exc = None
|
||||||
|
shared_exc = None
|
||||||
|
try:
|
||||||
|
store._resolve_datatype_iri(datatype)
|
||||||
|
except ValueError as exc:
|
||||||
|
store_exc = exc
|
||||||
|
try:
|
||||||
|
sparql_escaping.resolve_datatype_iri(datatype)
|
||||||
|
except ValueError as exc:
|
||||||
|
shared_exc = exc
|
||||||
|
|
||||||
|
self.assertIsNotNone(store_exc, f"Expected ValueError from store for {datatype!r}")
|
||||||
|
self.assertIsNotNone(shared_exc, f"Expected ValueError from shared module for {datatype!r}")
|
||||||
|
self.assertEqual(str(store_exc), str(shared_exc))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_connected_store() -> BlazegraphStore:
|
||||||
|
"""Create a BlazegraphStore instance bypassing the real _connect() call,
|
||||||
|
with .connected forced True (mirrors the state execute_sparql requires)."""
|
||||||
|
with patch.object(BlazegraphStore, "_connect", autospec=True):
|
||||||
|
store = BlazegraphStore(endpoint="http://localhost:9999/blazegraph")
|
||||||
|
store.connected = True
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlazegraphStoreConstructExtension(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
Tests for the Blazegraph CONSTRUCT extension: _is_construct_query
|
||||||
|
detection, execute_sparql's CONSTRUCT branch (Accept header, Turtle
|
||||||
|
parsing via rdflib, triples shape, ProcessingError on malformed Turtle),
|
||||||
|
and Property 9 (non-CONSTRUCT queries are byte-for-byte unaffected).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --- _is_construct_query detection ---
|
||||||
|
|
||||||
|
def test_is_construct_query_detects_uppercase_keyword(self):
|
||||||
|
store = _make_connected_store()
|
||||||
|
self.assertTrue(store._is_construct_query("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"))
|
||||||
|
|
||||||
|
def test_is_construct_query_detects_lowercase_keyword(self):
|
||||||
|
store = _make_connected_store()
|
||||||
|
self.assertTrue(store._is_construct_query("construct { ?s ?p ?o } where { ?s ?p ?o }"))
|
||||||
|
|
||||||
|
def test_is_construct_query_detects_mixed_case_keyword(self):
|
||||||
|
store = _make_connected_store()
|
||||||
|
self.assertTrue(store._is_construct_query("Construct { ?s ?p ?o } Where { ?s ?p ?o }"))
|
||||||
|
|
||||||
|
def test_is_construct_query_false_for_select(self):
|
||||||
|
store = _make_connected_store()
|
||||||
|
self.assertFalse(store._is_construct_query("SELECT ?s WHERE { ?s ?p ?o }"))
|
||||||
|
|
||||||
|
def test_is_construct_query_false_for_ask(self):
|
||||||
|
store = _make_connected_store()
|
||||||
|
self.assertFalse(store._is_construct_query("ASK { ?s ?p ?o }"))
|
||||||
|
|
||||||
|
def test_is_construct_query_does_not_match_substring_inside_identifier(self):
|
||||||
|
# "CONSTRUCTOR" contains "CONSTRUCT" as a substring but must not
|
||||||
|
# match due to \b word-boundary anchoring.
|
||||||
|
store = _make_connected_store()
|
||||||
|
self.assertFalse(
|
||||||
|
store._is_construct_query("SELECT ?s WHERE { ?s <urn:p> \"CONSTRUCTOR\" }")
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- execute_sparql CONSTRUCT path ---
|
||||||
|
|
||||||
|
def test_execute_sparql_construct_sends_turtle_accept_header(self):
|
||||||
|
store = _make_connected_store()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.content = (
|
||||||
|
b"@prefix ex: <http://ex.org/> .\n"
|
||||||
|
b'ex:s1 ex:p1 "value1" .\n'
|
||||||
|
)
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("semantica.triplet_store.blazegraph_store.requests.post", return_value=mock_response) as mock_post:
|
||||||
|
store.execute_sparql("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }")
|
||||||
|
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
self.assertEqual(kwargs["headers"]["Accept"], "text/turtle")
|
||||||
|
self.assertEqual(
|
||||||
|
kwargs["headers"]["Content-Type"], "application/x-www-form-urlencoded"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_execute_sparql_construct_parses_triples_from_fixed_turtle_fixture(self):
|
||||||
|
store = _make_connected_store()
|
||||||
|
turtle_fixture = (
|
||||||
|
b"@prefix ex: <http://ex.org/> .\n"
|
||||||
|
b'ex:s1 ex:p1 "value1" .\n'
|
||||||
|
b"ex:s1 ex:p2 ex:o2 .\n"
|
||||||
|
)
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.content = turtle_fixture
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("semantica.triplet_store.blazegraph_store.requests.post", return_value=mock_response):
|
||||||
|
result = store.execute_sparql("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }")
|
||||||
|
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(result["bindings"], [])
|
||||||
|
self.assertEqual(result["variables"], [])
|
||||||
|
self.assertEqual(result["metadata"]["result_format"], "construct")
|
||||||
|
|
||||||
|
triples = set(result["triples"])
|
||||||
|
self.assertIn(("http://ex.org/s1", "http://ex.org/p1", "value1"), triples)
|
||||||
|
self.assertIn(("http://ex.org/s1", "http://ex.org/p2", "http://ex.org/o2"), triples)
|
||||||
|
self.assertEqual(len(result["triples"]), 2)
|
||||||
|
|
||||||
|
def test_execute_sparql_construct_result_format_option_forces_construct_path(self):
|
||||||
|
# Even for a query that doesn't literally contain "CONSTRUCT",
|
||||||
|
# result_format="construct" should force the Turtle-parsing path.
|
||||||
|
store = _make_connected_store()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.content = b'@prefix ex: <http://ex.org/> .\nex:s1 ex:p1 "v" .\n'
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("semantica.triplet_store.blazegraph_store.requests.post", return_value=mock_response) as mock_post:
|
||||||
|
result = store.execute_sparql("SELECT ?s WHERE { ?s ?p ?o }", result_format="construct")
|
||||||
|
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
self.assertEqual(kwargs["headers"]["Accept"], "text/turtle")
|
||||||
|
self.assertIn("triples", result)
|
||||||
|
|
||||||
|
def test_execute_sparql_construct_malformed_turtle_raises_processing_error(self):
|
||||||
|
store = _make_connected_store()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
# Deliberately invalid Turtle syntax.
|
||||||
|
mock_response.content = b"this is { not [ valid turtle syntax at all !!!"
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("semantica.triplet_store.blazegraph_store.requests.post", return_value=mock_response):
|
||||||
|
with self.assertRaises(ProcessingError) as ctx:
|
||||||
|
store.execute_sparql("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }")
|
||||||
|
|
||||||
|
# Must be a ProcessingError, not a raw rdflib exception leaking out.
|
||||||
|
self.assertIsInstance(ctx.exception, ProcessingError)
|
||||||
|
self.assertNotIsInstance(ctx.exception, (SyntaxError, ValueError))
|
||||||
|
|
||||||
|
def test_execute_sparql_construct_handles_literal_with_braces_in_valid_turtle(self):
|
||||||
|
# Adversarial case implied by the brace-matching bug found in the
|
||||||
|
# template-string layer (construct_templates._find_matching_brace):
|
||||||
|
# confirm rdflib itself parses a *valid* Turtle literal containing
|
||||||
|
# brace characters correctly, since this is a different parsing
|
||||||
|
# layer (real Turtle syntax, not our {{param}} template string).
|
||||||
|
store = _make_connected_store()
|
||||||
|
turtle_fixture = (
|
||||||
|
b"@prefix ex: <http://ex.org/> .\n"
|
||||||
|
b'ex:s1 ex:p1 "text with { and } braces inside" .\n'
|
||||||
|
)
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.content = turtle_fixture
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("semantica.triplet_store.blazegraph_store.requests.post", return_value=mock_response):
|
||||||
|
result = store.execute_sparql("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }")
|
||||||
|
|
||||||
|
self.assertEqual(len(result["triples"]), 1)
|
||||||
|
subject, predicate, obj = result["triples"][0]
|
||||||
|
self.assertEqual(subject, "http://ex.org/s1")
|
||||||
|
self.assertEqual(predicate, "http://ex.org/p1")
|
||||||
|
self.assertEqual(obj, "text with { and } braces inside")
|
||||||
|
|
||||||
|
# --- Property 9: non-CONSTRUCT queries are byte-for-byte unaffected ---
|
||||||
|
|
||||||
|
def test_execute_sparql_select_query_response_shape_unchanged(self):
|
||||||
|
store = _make_connected_store()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"head": {"vars": ["s", "p", "o"]},
|
||||||
|
"results": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"s": {"type": "uri", "value": "http://ex.org/s1"},
|
||||||
|
"p": {"type": "uri", "value": "http://ex.org/p1"},
|
||||||
|
"o": {"type": "literal", "value": "v1"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("semantica.triplet_store.blazegraph_store.requests.post", return_value=mock_response) as mock_post:
|
||||||
|
result = store.execute_sparql("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
|
||||||
|
|
||||||
|
# Accept header must NOT be sent for a non-CONSTRUCT query — request
|
||||||
|
# shape is byte-for-byte identical to pre-CONSTRUCT-extension behavior.
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
self.assertEqual(kwargs["headers"], {"Content-Type": "application/x-www-form-urlencoded"})
|
||||||
|
self.assertNotIn("Accept", kwargs["headers"])
|
||||||
|
|
||||||
|
# Response shape must be exactly the pre-existing shape: no "triples"
|
||||||
|
# key at all (not even an empty list) for a plain SELECT response.
|
||||||
|
self.assertEqual(
|
||||||
|
result,
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"bindings": mock_response.json.return_value["results"]["bindings"],
|
||||||
|
"variables": ["s", "p", "o"],
|
||||||
|
"metadata": {
|
||||||
|
"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
|
||||||
|
"endpoint": store._get_sparql_endpoint(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertNotIn("triples", result)
|
||||||
|
|
||||||
|
def test_execute_sparql_ask_query_uses_bindings_path_not_construct(self):
|
||||||
|
store = _make_connected_store()
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {"head": {}, "boolean": True}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with patch("semantica.triplet_store.blazegraph_store.requests.post", return_value=mock_response) as mock_post:
|
||||||
|
store.execute_sparql("ASK { ?s ?p ?o }")
|
||||||
|
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
self.assertNotIn("Accept", kwargs["headers"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueryEngineConstructValidation(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
Confirms QueryEngine._validate_query requires zero changes for CONSTRUCT
|
||||||
|
support — CONSTRUCT was already a valid keyword before this feature.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_construct_query_passes_validation_unchanged(self):
|
||||||
|
from semantica.triplet_store.query_engine import QueryEngine
|
||||||
|
|
||||||
|
engine = QueryEngine()
|
||||||
|
query = "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"
|
||||||
|
self.assertTrue(engine._validate_query(query))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,966 @@
|
|||||||
|
"""
|
||||||
|
Tests for semantica.triplet_store.construct_templates.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- ConstructTemplateRegistry: register/get/list/remove + aliases,
|
||||||
|
duplicate-name rejection, registration-time validation errors.
|
||||||
|
- render_construct_template: parametrized coverage of Correctness
|
||||||
|
Properties 1 (literal escaping), 2/3 (URI allowlist + target_graph
|
||||||
|
parity), and 4 (typed-literal numeric rendering), per design.md's
|
||||||
|
"Parametrized coverage of Correctness Properties 1, 2, 3, 4" section.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rdflib import Graph
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
if PROJECT_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, PROJECT_ROOT)
|
||||||
|
|
||||||
|
from semantica.triplet_store.construct_templates import (
|
||||||
|
ConstructTemplate,
|
||||||
|
ConstructTemplateRegistry,
|
||||||
|
ParameterDescriptor,
|
||||||
|
_find_matching_brace,
|
||||||
|
_split_construct_query,
|
||||||
|
construct_template_step_handler,
|
||||||
|
execute_construct_template,
|
||||||
|
render_construct_template,
|
||||||
|
)
|
||||||
|
from semantica.utils.exceptions import ProcessingError, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
def _simple_template(**overrides) -> ConstructTemplate:
|
||||||
|
"""A minimal valid CONSTRUCT template: one literal param, no target_graph."""
|
||||||
|
defaults = dict(
|
||||||
|
name="simple_template",
|
||||||
|
description="A minimal template for testing.",
|
||||||
|
construct_query=(
|
||||||
|
"CONSTRUCT { <http://ex.org/s1> <http://ex.org/p1> {{value}} } "
|
||||||
|
"WHERE { <http://ex.org/s1> <http://ex.org/p1> {{value}} }"
|
||||||
|
),
|
||||||
|
parameters=[ParameterDescriptor(name="value", type="literal", required=True)],
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return ConstructTemplate(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def _uri_template(**overrides) -> ConstructTemplate:
|
||||||
|
"""A minimal valid CONSTRUCT template with one "uri"-typed param."""
|
||||||
|
defaults = dict(
|
||||||
|
name="uri_template",
|
||||||
|
description="A minimal template with a uri parameter.",
|
||||||
|
construct_query=(
|
||||||
|
"CONSTRUCT { {{subject}} <http://ex.org/p1> \"x\" } "
|
||||||
|
"WHERE { {{subject}} <http://ex.org/p1> \"x\" }"
|
||||||
|
),
|
||||||
|
parameters=[ParameterDescriptor(name="subject", type="uri", required=True)],
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return ConstructTemplate(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def _typed_literal_template(datatype: str, **overrides) -> ConstructTemplate:
|
||||||
|
"""A minimal valid CONSTRUCT template with one "typed-literal" param."""
|
||||||
|
defaults = dict(
|
||||||
|
name="typed_literal_template",
|
||||||
|
description="A minimal template with a typed-literal parameter.",
|
||||||
|
construct_query=(
|
||||||
|
"CONSTRUCT { <http://ex.org/s1> <http://ex.org/p1> {{value}} } "
|
||||||
|
"WHERE { <http://ex.org/s1> <http://ex.org/p1> {{value}} }"
|
||||||
|
),
|
||||||
|
parameters=[
|
||||||
|
ParameterDescriptor(
|
||||||
|
name="value", type="typed-literal", required=True, datatype=datatype
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return ConstructTemplate(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ConstructTemplateRegistry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstructTemplateRegistry(unittest.TestCase):
|
||||||
|
def test_register_and_get(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
template = _simple_template()
|
||||||
|
registry.register(template)
|
||||||
|
self.assertIs(registry.get("simple_template"), template)
|
||||||
|
|
||||||
|
def test_register_duplicate_name_raises_and_does_not_overwrite(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
t1 = _simple_template(description="original")
|
||||||
|
t2 = _simple_template(description="replacement")
|
||||||
|
registry.register(t1)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
registry.register(t2)
|
||||||
|
self.assertIs(registry.get("simple_template"), t1)
|
||||||
|
self.assertEqual(registry.get("simple_template").description, "original")
|
||||||
|
|
||||||
|
def test_register_rejects_missing_construct_keyword(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
bad_template = _simple_template(
|
||||||
|
construct_query="SELECT ?s WHERE { ?s ?p ?o }"
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
registry.register(bad_template)
|
||||||
|
self.assertIsNone(registry.get("simple_template"))
|
||||||
|
|
||||||
|
def test_register_rejects_typed_literal_without_datatype(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
bad_template = _simple_template(
|
||||||
|
parameters=[ParameterDescriptor(name="value", type="typed-literal", datatype=None)]
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
registry.register(bad_template)
|
||||||
|
self.assertIsNone(registry.get("simple_template"))
|
||||||
|
|
||||||
|
def test_get_miss_returns_none(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
self.assertIsNone(registry.get("nonexistent"))
|
||||||
|
|
||||||
|
def test_list_without_category_returns_all_names(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
registry.register(_simple_template(name="t1"))
|
||||||
|
registry.register(_simple_template(name="t2"))
|
||||||
|
self.assertEqual(sorted(registry.list()), ["t1", "t2"])
|
||||||
|
|
||||||
|
def test_list_with_category_filters(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
registry.register(_simple_template(name="t1", metadata={"category": "mapping"}))
|
||||||
|
registry.register(_simple_template(name="t2", metadata={"category": "other"}))
|
||||||
|
registry.register(_simple_template(name="t3", metadata={"category": "mapping"}))
|
||||||
|
self.assertEqual(sorted(registry.list(category="mapping")), ["t1", "t3"])
|
||||||
|
self.assertEqual(registry.list(category="other"), ["t2"])
|
||||||
|
self.assertEqual(registry.list(category="nonexistent_category"), [])
|
||||||
|
|
||||||
|
def test_remove_idempotence(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
registry.register(_simple_template())
|
||||||
|
self.assertTrue(registry.remove("simple_template"))
|
||||||
|
self.assertIsNone(registry.get("simple_template"))
|
||||||
|
self.assertFalse(registry.remove("simple_template"))
|
||||||
|
|
||||||
|
def test_register_template_alias_delegates_to_register(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
template = _simple_template()
|
||||||
|
registry.register_template(template)
|
||||||
|
self.assertIs(registry.get("simple_template"), template)
|
||||||
|
# Duplicate via alias still raises, same as register().
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
registry.register_template(_simple_template())
|
||||||
|
|
||||||
|
def test_get_template_alias_delegates_to_get(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
template = _simple_template()
|
||||||
|
registry.register(template)
|
||||||
|
self.assertIs(registry.get_template("simple_template"), template)
|
||||||
|
self.assertIsNone(registry.get_template("nonexistent"))
|
||||||
|
|
||||||
|
def test_list_templates_alias_delegates_to_list(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
registry.register(_simple_template(name="t1", metadata={"category": "mapping"}))
|
||||||
|
registry.register(_simple_template(name="t2"))
|
||||||
|
self.assertEqual(sorted(registry.list_templates()), ["t1", "t2"])
|
||||||
|
self.assertEqual(registry.list_templates(category="mapping"), ["t1"])
|
||||||
|
|
||||||
|
def test_get_template_info_returns_expected_shape(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
template = _simple_template(
|
||||||
|
target_graph="http://ex.org/graphs/g1",
|
||||||
|
metadata={"category": "mapping"},
|
||||||
|
)
|
||||||
|
registry.register(template)
|
||||||
|
info = registry.get_template_info("simple_template")
|
||||||
|
self.assertEqual(
|
||||||
|
info,
|
||||||
|
{
|
||||||
|
"name": "simple_template",
|
||||||
|
"description": "A minimal template for testing.",
|
||||||
|
"parameter_count": 1,
|
||||||
|
"target_graph": "http://ex.org/graphs/g1",
|
||||||
|
"metadata": {"category": "mapping"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_template_info_miss_returns_none(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
self.assertIsNone(registry.get_template_info("nonexistent"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property 1: literal escaping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
LITERAL_ESCAPING_CASES = [
|
||||||
|
pytest.param("back\\slash", id="backslash"),
|
||||||
|
pytest.param('embedded "double" quote', id="double-quote"),
|
||||||
|
pytest.param("line1\nline2", id="newline"),
|
||||||
|
pytest.param("line1\rline2", id="carriage-return"),
|
||||||
|
pytest.param("tab\there", id="tab"),
|
||||||
|
pytest.param("\\\"\n\r\t", id="all-combined"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderConstructTemplateLiteralEscaping:
|
||||||
|
"""Property 1: no unescaped injection via literal parameters."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw_value", LITERAL_ESCAPING_CASES)
|
||||||
|
def test_literal_param_round_trips_through_rdflib_turtle_parser(self, raw_value):
|
||||||
|
template = _simple_template()
|
||||||
|
rendered = render_construct_template(template, params={"value": raw_value})
|
||||||
|
|
||||||
|
# rendered must not contain an unescaped '"' that would terminate the
|
||||||
|
# literal early — verified by successfully round-tripping it through
|
||||||
|
# rdflib's Turtle parser back to the original string.
|
||||||
|
turtle_doc = f"@prefix ex: <http://ex.org/> .\nex:s1 ex:p1 {rendered.split('} WHERE')[0].split('<http://ex.org/p1>')[1].strip().rstrip('}').strip()} ."
|
||||||
|
graph = Graph()
|
||||||
|
graph.parse(data=turtle_doc, format="turtle")
|
||||||
|
literal_values = [str(o) for _, _, o in graph]
|
||||||
|
assert raw_value in literal_values
|
||||||
|
|
||||||
|
def test_render_produces_no_unescaped_quote_break(self):
|
||||||
|
template = _simple_template()
|
||||||
|
rendered = render_construct_template(
|
||||||
|
template, params={"value": 'has "quotes" inside'}
|
||||||
|
)
|
||||||
|
assert '"has \\"quotes\\" inside"' in rendered
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property 2/3: URI allowlist + target_graph parity
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
BAD_URI_CASES = [
|
||||||
|
pytest.param("javascript:alert(1)", id="javascript-scheme"),
|
||||||
|
pytest.param("file:///etc/passwd", id="file-scheme"),
|
||||||
|
pytest.param("http://example.com/has space", id="embedded-whitespace"),
|
||||||
|
pytest.param("http://example.com/<injected>", id="embedded-angle-brackets"),
|
||||||
|
pytest.param("http://example.com/{injected}", id="embedded-braces"),
|
||||||
|
pytest.param("http://example.com/#frag with space", id="fragment-with-whitespace"),
|
||||||
|
pytest.param("", id="empty-string"),
|
||||||
|
]
|
||||||
|
|
||||||
|
GOOD_URI_CASES = [
|
||||||
|
pytest.param("http://example.com/ok", id="http-scheme"),
|
||||||
|
pytest.param("https://example.com/ok", id="https-scheme"),
|
||||||
|
pytest.param("urn:isbn:0451450523", id="urn-scheme"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderConstructTemplateUriValidation:
|
||||||
|
"""Property 2 (URI allowlist enforcement) and Property 3 (target_graph parity)."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_uri", BAD_URI_CASES)
|
||||||
|
def test_uri_param_rejects_unsafe_values(self, bad_uri):
|
||||||
|
template = _uri_template()
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
render_construct_template(template, params={"subject": bad_uri})
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_uri", BAD_URI_CASES)
|
||||||
|
def test_target_graph_rejects_same_unsafe_values_as_uri_param(self, bad_uri):
|
||||||
|
# A template with a valid "uri" param, but an unsafe target_graph.
|
||||||
|
template = _uri_template()
|
||||||
|
valid_params = {"subject": "http://example.com/ok"}
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as uri_param_exc_info:
|
||||||
|
render_construct_template(
|
||||||
|
template, params={"subject": bad_uri}
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError) as target_graph_exc_info:
|
||||||
|
render_construct_template(
|
||||||
|
template, params=valid_params, target_graph=bad_uri
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parity check: both failure modes are ValidationError (same type);
|
||||||
|
# for non-empty invalid values, the underlying message text is
|
||||||
|
# identical because both call validate_uri() on the same bad_uri.
|
||||||
|
if bad_uri:
|
||||||
|
assert str(uri_param_exc_info.value) == str(target_graph_exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("good_uri", GOOD_URI_CASES)
|
||||||
|
def test_uri_param_accepts_allowed_schemes(self, good_uri):
|
||||||
|
template = _uri_template()
|
||||||
|
rendered = render_construct_template(template, params={"subject": good_uri})
|
||||||
|
assert f"<{good_uri}>" in rendered
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("good_uri", GOOD_URI_CASES)
|
||||||
|
def test_target_graph_accepts_same_allowed_schemes_as_uri_param(self, good_uri):
|
||||||
|
template = _uri_template()
|
||||||
|
rendered = render_construct_template(
|
||||||
|
template,
|
||||||
|
params={"subject": "http://example.com/ok"},
|
||||||
|
target_graph=good_uri,
|
||||||
|
)
|
||||||
|
assert f"GRAPH <{good_uri}>" in rendered
|
||||||
|
|
||||||
|
def test_template_target_graph_used_when_argument_omitted(self):
|
||||||
|
template = _uri_template(target_graph="http://ex.org/graphs/default")
|
||||||
|
rendered = render_construct_template(
|
||||||
|
template, params={"subject": "http://example.com/ok"}
|
||||||
|
)
|
||||||
|
assert "GRAPH <http://ex.org/graphs/default>" in rendered
|
||||||
|
|
||||||
|
def test_explicit_target_graph_overrides_template_default(self):
|
||||||
|
template = _uri_template(target_graph="http://ex.org/graphs/default")
|
||||||
|
rendered = render_construct_template(
|
||||||
|
template,
|
||||||
|
params={"subject": "http://example.com/ok"},
|
||||||
|
target_graph="http://ex.org/graphs/override",
|
||||||
|
)
|
||||||
|
assert "GRAPH <http://ex.org/graphs/override>" in rendered
|
||||||
|
assert "GRAPH <http://ex.org/graphs/default>" not in rendered
|
||||||
|
|
||||||
|
def test_no_target_graph_means_no_graph_wrapping(self):
|
||||||
|
template = _uri_template()
|
||||||
|
rendered = render_construct_template(
|
||||||
|
template, params={"subject": "http://example.com/ok"}
|
||||||
|
)
|
||||||
|
assert "GRAPH" not in rendered
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Property 4: typed-literal numeric rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
TYPED_LITERAL_CASES = [
|
||||||
|
pytest.param("xsd:integer", 42, True, id="xsd-integer"),
|
||||||
|
pytest.param("xsd:int", -7, True, id="xsd-int"),
|
||||||
|
pytest.param("xsd:long", 9999999999, True, id="xsd-long"),
|
||||||
|
pytest.param("xsd:short", 12, True, id="xsd-short"),
|
||||||
|
pytest.param("xsd:decimal", 3.14, True, id="xsd-decimal"),
|
||||||
|
pytest.param("xsd:double", 2.5e10, True, id="xsd-double"),
|
||||||
|
pytest.param("xsd:float", 1.5, True, id="xsd-float"),
|
||||||
|
pytest.param("xsd:boolean", True, True, id="xsd-boolean-true"),
|
||||||
|
pytest.param("xsd:boolean", False, True, id="xsd-boolean-false"),
|
||||||
|
pytest.param("xsd:dateTime", "2024-01-01T00:00:00Z", False, id="xsd-datetime-contrast"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderConstructTemplateTypedLiteralRendering:
|
||||||
|
"""Property 4: typed-literal numeric rendering is unquoted, non-numeric is quoted."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("datatype,value,expect_unquoted", TYPED_LITERAL_CASES)
|
||||||
|
def test_typed_literal_rendering_by_datatype(self, datatype, value, expect_unquoted):
|
||||||
|
template = _typed_literal_template(datatype)
|
||||||
|
rendered = render_construct_template(template, params={"value": value})
|
||||||
|
|
||||||
|
# Extract the substituted token from the rendered query.
|
||||||
|
placeholder_start = rendered.index("<http://ex.org/p1>") + len("<http://ex.org/p1> ")
|
||||||
|
placeholder_end = rendered.index(" }", placeholder_start)
|
||||||
|
substituted = rendered[placeholder_start:placeholder_end]
|
||||||
|
|
||||||
|
if expect_unquoted:
|
||||||
|
assert not substituted.startswith('"'), (
|
||||||
|
f"Expected unquoted rendering for {datatype}, got: {substituted!r}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert substituted.startswith('"') and "^^<" in substituted, (
|
||||||
|
f"Expected quoted ^^<iri> rendering for {datatype}, got: {substituted!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# rdflib should parse the resulting triple with the expected Literal.
|
||||||
|
prefix = "@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n"
|
||||||
|
turtle_doc = (
|
||||||
|
prefix
|
||||||
|
+ f"<http://ex.org/s1> <http://ex.org/p1> {substituted} ."
|
||||||
|
)
|
||||||
|
graph = Graph()
|
||||||
|
graph.parse(data=turtle_doc, format="turtle")
|
||||||
|
literals = [o for _, _, o in graph]
|
||||||
|
assert len(literals) == 1
|
||||||
|
|
||||||
|
def test_typed_literal_rejects_non_numeric_value_for_integer(self):
|
||||||
|
template = _typed_literal_template("xsd:integer")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
render_construct_template(template, params={"value": "not-a-number"})
|
||||||
|
|
||||||
|
def test_typed_literal_missing_datatype_raises(self):
|
||||||
|
template = _simple_template(
|
||||||
|
parameters=[ParameterDescriptor(name="value", type="typed-literal", datatype=None)]
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
render_construct_template(template, params={"value": 42})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Required-parameter completeness (Property 5, spot-checked alongside 1-4)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderConstructTemplateRequiredParameters:
|
||||||
|
def test_missing_required_parameter_raises(self):
|
||||||
|
template = _simple_template()
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
render_construct_template(template, params={})
|
||||||
|
|
||||||
|
def test_optional_parameter_falls_back_to_default(self):
|
||||||
|
template = _simple_template(
|
||||||
|
parameters=[
|
||||||
|
ParameterDescriptor(name="value", type="literal", required=False, default="fallback")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rendered = render_construct_template(template, params={})
|
||||||
|
assert '"fallback"' in rendered
|
||||||
|
|
||||||
|
def test_unresolved_placeholder_with_no_descriptor_raises(self):
|
||||||
|
template = _simple_template(
|
||||||
|
construct_query=(
|
||||||
|
"CONSTRUCT { <http://ex.org/s1> <http://ex.org/p1> {{value}} ; "
|
||||||
|
"<http://ex.org/p2> {{undeclared}} } "
|
||||||
|
"WHERE { <http://ex.org/s1> <http://ex.org/p1> {{value}} }"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
render_construct_template(template, params={"value": "x"})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _split_construct_query / _find_matching_brace: CONSTRUCT/WHERE boundary
|
||||||
|
# parsing. This logic was added to fill a gap left as unspecified stubs
|
||||||
|
# (extract_construct_clause/extract_where_body) in design.md; it is
|
||||||
|
# exercised whenever target_graph wrapping is requested.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSplitConstructQuery(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
Direct coverage of _split_construct_query / _find_matching_brace against
|
||||||
|
the 5 scenarios called out for verification, plus a regression case for
|
||||||
|
a bug found during that verification (see test 5b).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_1_prefix_declarations_before_construct_are_preserved_as_preamble(self):
|
||||||
|
query = (
|
||||||
|
"PREFIX foaf: <http://xmlns.com/foaf/0.1/>\n"
|
||||||
|
'CONSTRUCT { <http://ex.org/s1> foaf:name "Alice" } '
|
||||||
|
'WHERE { <http://ex.org/s1> foaf:name "Alice" }'
|
||||||
|
)
|
||||||
|
preamble, construct_clause, where_body = _split_construct_query(query)
|
||||||
|
self.assertEqual(preamble, "PREFIX foaf: <http://xmlns.com/foaf/0.1/>")
|
||||||
|
self.assertEqual(construct_clause, '{ <http://ex.org/s1> foaf:name "Alice" }')
|
||||||
|
self.assertEqual(where_body.strip(), '<http://ex.org/s1> foaf:name "Alice"')
|
||||||
|
|
||||||
|
def test_1b_target_graph_wrapping_preserves_prefix_preamble(self):
|
||||||
|
# End-to-end: PREFIX preamble must survive graph-wrapping, not just
|
||||||
|
# the standalone splitter.
|
||||||
|
template = ConstructTemplate(
|
||||||
|
name="prefixed_template",
|
||||||
|
description="test",
|
||||||
|
construct_query=(
|
||||||
|
"PREFIX foaf: <http://xmlns.com/foaf/0.1/>\n"
|
||||||
|
'CONSTRUCT { <http://ex.org/s1> foaf:name "Alice" } '
|
||||||
|
'WHERE { <http://ex.org/s1> foaf:name "Alice" }'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rendered = render_construct_template(
|
||||||
|
template, params={}, target_graph="http://ex.org/graphs/g1"
|
||||||
|
)
|
||||||
|
self.assertTrue(rendered.startswith("PREFIX foaf: <http://xmlns.com/foaf/0.1/>"))
|
||||||
|
self.assertIn("GRAPH <http://ex.org/graphs/g1>", rendered)
|
||||||
|
|
||||||
|
def test_2_nested_braces_in_construct_clause_blank_node_pattern(self):
|
||||||
|
query = (
|
||||||
|
"CONSTRUCT { ?s ?p [ <http://ex.org/p2> ?o2 ] } "
|
||||||
|
"WHERE { ?s ?p ?o2 }"
|
||||||
|
)
|
||||||
|
preamble, construct_clause, where_body = _split_construct_query(query)
|
||||||
|
self.assertEqual(preamble, "")
|
||||||
|
self.assertEqual(construct_clause, "{ ?s ?p [ <http://ex.org/p2> ?o2 ] }")
|
||||||
|
self.assertEqual(where_body.strip(), "?s ?p ?o2")
|
||||||
|
|
||||||
|
def test_2b_nested_braces_in_construct_clause_graph_pattern(self):
|
||||||
|
# A CONSTRUCT clause containing an actual nested { ... } (not just
|
||||||
|
# blank-node [ ... ] syntax), to directly exercise brace-depth
|
||||||
|
# tracking rather than square-bracket handling.
|
||||||
|
query = (
|
||||||
|
"CONSTRUCT { GRAPH <http://ex.org/g1> { ?s ?p ?o } } "
|
||||||
|
"WHERE { ?s ?p ?o }"
|
||||||
|
)
|
||||||
|
preamble, construct_clause, where_body = _split_construct_query(query)
|
||||||
|
self.assertEqual(construct_clause, "{ GRAPH <http://ex.org/g1> { ?s ?p ?o } }")
|
||||||
|
self.assertEqual(where_body.strip(), "?s ?p ?o")
|
||||||
|
|
||||||
|
def test_3_nested_braces_in_where_clause_optional_block(self):
|
||||||
|
query = (
|
||||||
|
"CONSTRUCT { ?s <http://ex.org/p1> ?o } "
|
||||||
|
"WHERE { ?s <http://ex.org/p1> ?o . OPTIONAL { ?s <http://ex.org/p2> ?o2 } }"
|
||||||
|
)
|
||||||
|
preamble, construct_clause, where_body = _split_construct_query(query)
|
||||||
|
self.assertEqual(construct_clause, "{ ?s <http://ex.org/p1> ?o }")
|
||||||
|
self.assertEqual(
|
||||||
|
where_body.strip(),
|
||||||
|
"?s <http://ex.org/p1> ?o . OPTIONAL { ?s <http://ex.org/p2> ?o2 }",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_3b_nested_braces_in_where_clause_filter_not_exists_block(self):
|
||||||
|
query = (
|
||||||
|
"CONSTRUCT { ?s <http://ex.org/p1> ?o } "
|
||||||
|
"WHERE { ?s <http://ex.org/p1> ?o . "
|
||||||
|
"FILTER NOT EXISTS { ?s <http://ex.org/p2> ?o2 } }"
|
||||||
|
)
|
||||||
|
preamble, construct_clause, where_body = _split_construct_query(query)
|
||||||
|
self.assertEqual(construct_clause, "{ ?s <http://ex.org/p1> ?o }")
|
||||||
|
self.assertEqual(
|
||||||
|
where_body.strip(),
|
||||||
|
"?s <http://ex.org/p1> ?o . FILTER NOT EXISTS { ?s <http://ex.org/p2> ?o2 }",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_4_where_keyword_like_substring_inside_construct_clause_literal(self):
|
||||||
|
# "WHERE" appearing inside a string literal in the CONSTRUCT clause
|
||||||
|
# must not be mistaken for the real WHERE keyword.
|
||||||
|
query = (
|
||||||
|
'CONSTRUCT { <http://ex.org/s1> <http://ex.org/p1> "the WHERE clause matters" } '
|
||||||
|
"WHERE { <http://ex.org/s1> <http://ex.org/p1> ?o }"
|
||||||
|
)
|
||||||
|
preamble, construct_clause, where_body = _split_construct_query(query)
|
||||||
|
self.assertEqual(
|
||||||
|
construct_clause,
|
||||||
|
'{ <http://ex.org/s1> <http://ex.org/p1> "the WHERE clause matters" }',
|
||||||
|
)
|
||||||
|
self.assertEqual(where_body.strip(), "<http://ex.org/s1> <http://ex.org/p1> ?o")
|
||||||
|
|
||||||
|
def test_4b_brace_character_inside_where_clause_literal_does_not_break_matching(self):
|
||||||
|
# A '}' character embedded inside a string literal within the WHERE
|
||||||
|
# body must not be counted as a real closing brace by
|
||||||
|
# _find_matching_brace (regression test for a bug found during
|
||||||
|
# verification: braces inside literals were originally counted
|
||||||
|
# regardless of string-literal context, corrupting brace-depth
|
||||||
|
# tracking and truncating/duplicating query text).
|
||||||
|
query = (
|
||||||
|
"CONSTRUCT { <http://ex.org/s1> <http://ex.org/p1> ?o } "
|
||||||
|
'WHERE { <http://ex.org/s1> <http://ex.org/p1> "text with } inside" }'
|
||||||
|
)
|
||||||
|
preamble, construct_clause, where_body = _split_construct_query(query)
|
||||||
|
self.assertEqual(construct_clause, "{ <http://ex.org/s1> <http://ex.org/p1> ?o }")
|
||||||
|
self.assertEqual(
|
||||||
|
where_body.strip(),
|
||||||
|
'<http://ex.org/s1> <http://ex.org/p1> "text with } inside"',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_4c_end_to_end_literal_with_brace_survives_target_graph_wrapping(self):
|
||||||
|
# End-to-end regression test: a rendered "literal" parameter value
|
||||||
|
# containing a '}' character must survive target_graph wrapping
|
||||||
|
# intact, not get truncated by brace-depth confusion.
|
||||||
|
template = ConstructTemplate(
|
||||||
|
name="brace_in_literal",
|
||||||
|
description="test",
|
||||||
|
construct_query=(
|
||||||
|
"CONSTRUCT { <http://ex.org/s1> <http://ex.org/p1> {{value}} } "
|
||||||
|
"WHERE { <http://ex.org/s1> <http://ex.org/p1> {{value}} }"
|
||||||
|
),
|
||||||
|
parameters=[ParameterDescriptor(name="value", type="literal", required=True)],
|
||||||
|
)
|
||||||
|
rendered = render_construct_template(
|
||||||
|
template,
|
||||||
|
params={"value": "text with } inside"},
|
||||||
|
target_graph="http://ex.org/graphs/g1",
|
||||||
|
)
|
||||||
|
self.assertIn('"text with } inside"', rendered)
|
||||||
|
# Must appear intact in both the CONSTRUCT clause and the GRAPH-wrapped
|
||||||
|
# WHERE body — not truncated at the embedded '}'.
|
||||||
|
self.assertEqual(rendered.count('"text with } inside"'), 2)
|
||||||
|
|
||||||
|
def test_5_mismatched_braces_more_open_than_close_raises_validation_error(self):
|
||||||
|
query = "CONSTRUCT { ?s ?p ?o WHERE { ?s ?p ?o }"
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
_split_construct_query(query)
|
||||||
|
|
||||||
|
def test_5b_mismatched_braces_end_to_end_via_target_graph_wrapping(self):
|
||||||
|
# End-to-end: a malformed construct_query only surfaces the brace
|
||||||
|
# mismatch when target_graph wrapping is requested (no wrapping
|
||||||
|
# needed => no splitting attempted => no error from this code path).
|
||||||
|
template = ConstructTemplate(
|
||||||
|
name="malformed_template",
|
||||||
|
description="test",
|
||||||
|
construct_query="CONSTRUCT { ?s ?p ?o WHERE { ?s ?p ?o }",
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
render_construct_template(
|
||||||
|
template, params={}, target_graph="http://ex.org/graphs/g1"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_find_matching_brace_raises_on_no_closing_brace(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
_find_matching_brace("{ unterminated", 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# execute_construct_template: render -> execute -> convert -> persist.
|
||||||
|
# Property 6 (round-trip persistence) coverage via a stub store_backend.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _StubStoreBackend:
|
||||||
|
"""
|
||||||
|
Minimal stub store_backend for execute_construct_template tests.
|
||||||
|
|
||||||
|
execute_sparql returns a fixed CONSTRUCT-shaped result; add_triplets
|
||||||
|
records exactly what it was called with and returns a caller-controlled
|
||||||
|
success/failure result.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, triples, add_triplets_result=None, execute_sparql_error=None):
|
||||||
|
self._triples = triples
|
||||||
|
self._add_triplets_result = add_triplets_result or {"success": True}
|
||||||
|
self._execute_sparql_error = execute_sparql_error
|
||||||
|
self.execute_sparql_calls = []
|
||||||
|
self.add_triplets_calls = []
|
||||||
|
|
||||||
|
def execute_sparql(self, query, **options):
|
||||||
|
self.execute_sparql_calls.append((query, options))
|
||||||
|
if self._execute_sparql_error is not None:
|
||||||
|
raise self._execute_sparql_error
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"bindings": [],
|
||||||
|
"variables": [],
|
||||||
|
"triples": self._triples,
|
||||||
|
"metadata": {"query": query, "result_format": "construct"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_triplets(self, triplets, **options):
|
||||||
|
self.add_triplets_calls.append((triplets, options))
|
||||||
|
return self._add_triplets_result
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteConstructTemplate(unittest.TestCase):
|
||||||
|
def _template(self, target_graph=None):
|
||||||
|
return ConstructTemplate(
|
||||||
|
name="exec_test_template",
|
||||||
|
description="test",
|
||||||
|
construct_query=(
|
||||||
|
"CONSTRUCT { <http://ex.org/s1> <http://ex.org/p1> {{value}} } "
|
||||||
|
"WHERE { <http://ex.org/s1> <http://ex.org/p1> {{value}} }"
|
||||||
|
),
|
||||||
|
parameters=[ParameterDescriptor(name="value", type="literal", required=True)],
|
||||||
|
target_graph=target_graph,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 1. Happy path ---
|
||||||
|
|
||||||
|
def test_happy_path_returns_triplets_and_calls_add_triplets_with_same_list(self):
|
||||||
|
fixed_triples = [
|
||||||
|
("http://ex.org/s1", "http://ex.org/p1", "v1"),
|
||||||
|
("http://ex.org/s2", "http://ex.org/p2", "v2"),
|
||||||
|
]
|
||||||
|
stub = _StubStoreBackend(triples=fixed_triples)
|
||||||
|
template = self._template()
|
||||||
|
|
||||||
|
result = execute_construct_template(template, {"value": "x"}, stub)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
[(t.subject, t.predicate, t.object) for t in result],
|
||||||
|
fixed_triples,
|
||||||
|
)
|
||||||
|
for triplet in result:
|
||||||
|
self.assertEqual(
|
||||||
|
triplet.metadata, {"source": "construct_template", "template": "exec_test_template"}
|
||||||
|
)
|
||||||
|
# confidence is explicitly 1.0 (documented, deliberate default —
|
||||||
|
# CONSTRUCT results are deterministic, not probabilistic extraction).
|
||||||
|
self.assertEqual(triplet.confidence, 1.0)
|
||||||
|
|
||||||
|
# add_triplets must have been called with exactly this list (same
|
||||||
|
# object, not a copy or a differently-ordered list).
|
||||||
|
self.assertEqual(len(stub.add_triplets_calls), 1)
|
||||||
|
called_triplets, called_options = stub.add_triplets_calls[0]
|
||||||
|
self.assertIs(called_triplets, result)
|
||||||
|
|
||||||
|
# --- 2. Missing add_triplets ---
|
||||||
|
|
||||||
|
def test_missing_add_triplets_raises_before_execute_sparql_is_called(self):
|
||||||
|
class NoAddTriplets:
|
||||||
|
def execute_sparql(self, query, **options):
|
||||||
|
raise AssertionError("execute_sparql must not be called")
|
||||||
|
|
||||||
|
stub = NoAddTriplets()
|
||||||
|
template = self._template()
|
||||||
|
|
||||||
|
with self.assertRaises(ProcessingError):
|
||||||
|
execute_construct_template(template, {"value": "x"}, stub)
|
||||||
|
|
||||||
|
def test_missing_add_triplets_execute_sparql_not_called_verified_via_mock(self):
|
||||||
|
stub = MagicMock()
|
||||||
|
del stub.add_triplets # hasattr(stub, "add_triplets") -> False
|
||||||
|
template = self._template()
|
||||||
|
|
||||||
|
with self.assertRaises(ProcessingError):
|
||||||
|
execute_construct_template(template, {"value": "x"}, stub)
|
||||||
|
|
||||||
|
stub.execute_sparql.assert_not_called()
|
||||||
|
|
||||||
|
# --- 3. Missing execute_sparql ---
|
||||||
|
|
||||||
|
def test_missing_execute_sparql_raises_before_any_query_attempted(self):
|
||||||
|
stub = MagicMock()
|
||||||
|
del stub.execute_sparql # hasattr(stub, "execute_sparql") -> False
|
||||||
|
template = self._template()
|
||||||
|
|
||||||
|
with self.assertRaises(ProcessingError):
|
||||||
|
execute_construct_template(template, {"value": "x"}, stub)
|
||||||
|
|
||||||
|
stub.add_triplets.assert_not_called()
|
||||||
|
|
||||||
|
# --- 4. add_triplets reports failure ---
|
||||||
|
|
||||||
|
def test_add_triplets_failure_raises_processing_error_not_partial_list(self):
|
||||||
|
fixed_triples = [("http://ex.org/s1", "http://ex.org/p1", "v1")]
|
||||||
|
stub = _StubStoreBackend(
|
||||||
|
triples=fixed_triples,
|
||||||
|
add_triplets_result={"success": False, "error": "write failed"},
|
||||||
|
)
|
||||||
|
template = self._template()
|
||||||
|
|
||||||
|
with self.assertRaises(ProcessingError):
|
||||||
|
execute_construct_template(template, {"value": "x"}, stub)
|
||||||
|
|
||||||
|
# add_triplets was still attempted (with the constructed triplets)...
|
||||||
|
self.assertEqual(len(stub.add_triplets_calls), 1)
|
||||||
|
# ...but the function must not have returned anything — verified by
|
||||||
|
# the exception itself; there is no return value to inspect because
|
||||||
|
# execute_construct_template raised instead of returning.
|
||||||
|
|
||||||
|
# --- 5. execute_sparql raises an exception ---
|
||||||
|
|
||||||
|
def test_execute_sparql_exception_propagates_without_being_swallowed(self):
|
||||||
|
# Report exactly what happens: execute_construct_template does not
|
||||||
|
# wrap or catch exceptions raised by store_backend.execute_sparql —
|
||||||
|
# it calls it directly and lets whatever it raises propagate as-is.
|
||||||
|
network_error = ProcessingError("SPARQL query failed: connection refused")
|
||||||
|
stub = _StubStoreBackend(triples=[], execute_sparql_error=network_error)
|
||||||
|
template = self._template()
|
||||||
|
|
||||||
|
with self.assertRaises(ProcessingError) as ctx:
|
||||||
|
execute_construct_template(template, {"value": "x"}, stub)
|
||||||
|
|
||||||
|
# The exact same exception instance propagates unchanged (not
|
||||||
|
# re-wrapped in a new ProcessingError with a different message).
|
||||||
|
self.assertIs(ctx.exception, network_error)
|
||||||
|
|
||||||
|
def test_execute_sparql_arbitrary_exception_type_also_propagates_unwrapped(self):
|
||||||
|
# Confirm this holds even for a non-ProcessingError exception type
|
||||||
|
# (e.g. a raw network library error) — execute_construct_template
|
||||||
|
# does not catch-and-wrap at all; add_triplets is simply never
|
||||||
|
# reached because execute_sparql raised first.
|
||||||
|
original_error = ConnectionError("simulated network failure")
|
||||||
|
stub = _StubStoreBackend(triples=[], execute_sparql_error=original_error)
|
||||||
|
template = self._template()
|
||||||
|
|
||||||
|
with self.assertRaises(ConnectionError) as ctx:
|
||||||
|
execute_construct_template(template, {"value": "x"}, stub)
|
||||||
|
|
||||||
|
self.assertIs(ctx.exception, original_error)
|
||||||
|
self.assertEqual(stub.add_triplets_calls, [])
|
||||||
|
|
||||||
|
# --- 6. target_graph precedence ---
|
||||||
|
|
||||||
|
def test_target_graph_argument_overrides_template_target_graph(self):
|
||||||
|
stub = _StubStoreBackend(triples=[])
|
||||||
|
template = self._template(target_graph="http://ex.org/graphs/template_default")
|
||||||
|
|
||||||
|
execute_construct_template(
|
||||||
|
template, {"value": "x"}, stub, target_graph="http://ex.org/graphs/explicit"
|
||||||
|
)
|
||||||
|
|
||||||
|
_, add_triplets_options = stub.add_triplets_calls[0]
|
||||||
|
self.assertEqual(add_triplets_options["graph"], "http://ex.org/graphs/explicit")
|
||||||
|
|
||||||
|
def test_template_target_graph_used_when_argument_is_none(self):
|
||||||
|
stub = _StubStoreBackend(triples=[])
|
||||||
|
template = self._template(target_graph="http://ex.org/graphs/template_default")
|
||||||
|
|
||||||
|
execute_construct_template(template, {"value": "x"}, stub, target_graph=None)
|
||||||
|
|
||||||
|
_, add_triplets_options = stub.add_triplets_calls[0]
|
||||||
|
self.assertEqual(add_triplets_options["graph"], "http://ex.org/graphs/template_default")
|
||||||
|
|
||||||
|
def test_no_target_graph_anywhere_passes_none_to_add_triplets(self):
|
||||||
|
stub = _StubStoreBackend(triples=[])
|
||||||
|
template = self._template(target_graph=None)
|
||||||
|
|
||||||
|
execute_construct_template(template, {"value": "x"}, stub)
|
||||||
|
|
||||||
|
_, add_triplets_options = stub.add_triplets_calls[0]
|
||||||
|
self.assertIsNone(add_triplets_options["graph"])
|
||||||
|
|
||||||
|
# --- 7. render_construct_template's ValidationError propagates unchanged ---
|
||||||
|
|
||||||
|
def test_missing_required_param_validation_error_propagates_unwrapped(self):
|
||||||
|
stub = _StubStoreBackend(triples=[])
|
||||||
|
template = self._template()
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
# Omit the required "value" parameter entirely.
|
||||||
|
execute_construct_template(template, {}, stub)
|
||||||
|
|
||||||
|
# Rendering must have failed before any query was attempted.
|
||||||
|
self.assertEqual(stub.execute_sparql_calls, [])
|
||||||
|
self.assertEqual(stub.add_triplets_calls, [])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# construct_template_step_handler: pipeline integration.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstructTemplateStepHandler(unittest.TestCase):
|
||||||
|
def _template(self):
|
||||||
|
return ConstructTemplate(
|
||||||
|
name="step_handler_template",
|
||||||
|
description="test",
|
||||||
|
construct_query=(
|
||||||
|
"CONSTRUCT { <http://ex.org/s1> <http://ex.org/p1> {{value}} } "
|
||||||
|
"WHERE { <http://ex.org/s1> <http://ex.org/p1> {{value}} }"
|
||||||
|
),
|
||||||
|
parameters=[ParameterDescriptor(name="value", type="literal", required=True)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_store_backend_raises_processing_error(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
registry.register(self._template())
|
||||||
|
|
||||||
|
with self.assertRaises(ProcessingError):
|
||||||
|
construct_template_step_handler(
|
||||||
|
None,
|
||||||
|
template_name="step_handler_template",
|
||||||
|
params={"value": "x"},
|
||||||
|
construct_template_registry=registry,
|
||||||
|
# store_backend deliberately omitted
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_registry_raises_processing_error(self):
|
||||||
|
stub = _StubStoreBackend(triples=[])
|
||||||
|
|
||||||
|
with self.assertRaises(ProcessingError):
|
||||||
|
construct_template_step_handler(
|
||||||
|
None,
|
||||||
|
template_name="step_handler_template",
|
||||||
|
params={"value": "x"},
|
||||||
|
store_backend=stub,
|
||||||
|
# construct_template_registry deliberately omitted
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_template_name_raises_validation_error(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
registry.register(self._template())
|
||||||
|
stub = _StubStoreBackend(triples=[])
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
construct_template_step_handler(
|
||||||
|
None,
|
||||||
|
template_name="does_not_exist",
|
||||||
|
params={"value": "x"},
|
||||||
|
store_backend=stub,
|
||||||
|
construct_template_registry=registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_happy_path_delegates_to_execute_construct_template(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
registry.register(self._template())
|
||||||
|
fixed_triples = [("http://ex.org/s1", "http://ex.org/p1", "hello")]
|
||||||
|
stub = _StubStoreBackend(triples=fixed_triples)
|
||||||
|
|
||||||
|
result = construct_template_step_handler(
|
||||||
|
None,
|
||||||
|
template_name="step_handler_template",
|
||||||
|
params={"value": "hello"},
|
||||||
|
store_backend=stub,
|
||||||
|
construct_template_registry=registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[(t.subject, t.predicate, t.object) for t in result],
|
||||||
|
fixed_triples,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(stub.add_triplets_calls), 1)
|
||||||
|
|
||||||
|
def test_engine_config_fallback_used_when_options_omit_store_backend(self):
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
registry.register(self._template())
|
||||||
|
stub = _StubStoreBackend(triples=[("http://ex.org/s1", "http://ex.org/p1", "v")])
|
||||||
|
|
||||||
|
result = construct_template_step_handler(
|
||||||
|
None,
|
||||||
|
template_name="step_handler_template",
|
||||||
|
params={"value": "v"},
|
||||||
|
engine_config={"store_backend": stub, "construct_template_registry": registry},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstructTemplatePipelineIntegration(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
End-to-end: PipelineBuilder.add_step with the "construct_template" step
|
||||||
|
type -> ExecutionEngine.execute_pipeline with store_backend + registry
|
||||||
|
passed in -> triplets returned/persisted. Confirms Requirement 7.5 (no
|
||||||
|
PipelineStep/_execute_step changes needed) holds in practice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_construct_template_step_executes_end_to_end_via_execution_engine(self):
|
||||||
|
from semantica.pipeline import ExecutionEngine, PipelineBuilder
|
||||||
|
|
||||||
|
registry = ConstructTemplateRegistry()
|
||||||
|
registry.register(
|
||||||
|
ConstructTemplate(
|
||||||
|
name="e2e_template",
|
||||||
|
description="test",
|
||||||
|
construct_query=(
|
||||||
|
"CONSTRUCT { <http://ex.org/s1> <http://ex.org/p1> {{value}} } "
|
||||||
|
"WHERE { <http://ex.org/s1> <http://ex.org/p1> {{value}} }"
|
||||||
|
),
|
||||||
|
parameters=[ParameterDescriptor(name="value", type="literal", required=True)],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fixed_triples = [("http://ex.org/s1", "http://ex.org/p1", "Alice")]
|
||||||
|
stub = _StubStoreBackend(triples=fixed_triples)
|
||||||
|
|
||||||
|
builder = PipelineBuilder()
|
||||||
|
builder.add_step(
|
||||||
|
"apply_e2e_template",
|
||||||
|
"construct_template",
|
||||||
|
handler=construct_template_step_handler,
|
||||||
|
template_name="e2e_template",
|
||||||
|
params={"value": "Alice"},
|
||||||
|
)
|
||||||
|
pipeline = builder.build(name="e2e_pipeline")
|
||||||
|
|
||||||
|
engine = ExecutionEngine()
|
||||||
|
result = engine.execute_pipeline(
|
||||||
|
pipeline,
|
||||||
|
data=None,
|
||||||
|
store_backend=stub,
|
||||||
|
construct_template_registry=registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.success, msg=f"Pipeline failed: {result.errors}")
|
||||||
|
returned_triplets = result.output
|
||||||
|
self.assertEqual(
|
||||||
|
[(t.subject, t.predicate, t.object) for t in returned_triplets],
|
||||||
|
fixed_triples,
|
||||||
|
)
|
||||||
|
# Confirm the triples were actually persisted via add_triplets.
|
||||||
|
self.assertEqual(len(stub.add_triplets_calls), 1)
|
||||||
|
persisted_triplets, _ = stub.add_triplets_calls[0]
|
||||||
|
self.assertEqual(
|
||||||
|
[(t.subject, t.predicate, t.object) for t in persisted_triplets],
|
||||||
|
fixed_triples,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
Tests for semantica.triplet_store.query_engine, focused on the
|
||||||
|
QueryResult.triples field added for CONSTRUCT query support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
if PROJECT_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, PROJECT_ROOT)
|
||||||
|
|
||||||
|
from semantica.triplet_store.query_engine import QueryEngine, QueryResult
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueryResultTriplesField(unittest.TestCase):
|
||||||
|
def test_query_result_triples_defaults_to_empty_list(self):
|
||||||
|
result = QueryResult(bindings=[], variables=[])
|
||||||
|
self.assertEqual(result.triples, [])
|
||||||
|
|
||||||
|
def test_query_result_triples_field_is_independent_per_instance(self):
|
||||||
|
# default_factory=list must produce a fresh list per instance, not a
|
||||||
|
# shared mutable default.
|
||||||
|
r1 = QueryResult(bindings=[], variables=[])
|
||||||
|
r2 = QueryResult(bindings=[], variables=[])
|
||||||
|
r1.triples.append(("s", "p", "o"))
|
||||||
|
self.assertEqual(r1.triples, [("s", "p", "o")])
|
||||||
|
self.assertEqual(r2.triples, [])
|
||||||
|
|
||||||
|
def test_query_result_accepts_explicit_triples(self):
|
||||||
|
triples = [("http://ex.org/s1", "http://ex.org/p1", "v1")]
|
||||||
|
result = QueryResult(bindings=[], variables=[], triples=triples)
|
||||||
|
self.assertEqual(result.triples, triples)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConstructBackend:
|
||||||
|
"""Fake store_backend whose execute_sparql returns a CONSTRUCT-shaped result."""
|
||||||
|
|
||||||
|
supports_named_graphs = True
|
||||||
|
|
||||||
|
def execute_sparql(self, query, **options):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"bindings": [],
|
||||||
|
"variables": [],
|
||||||
|
"triples": [
|
||||||
|
("http://ex.org/s1", "http://ex.org/p1", "v1"),
|
||||||
|
("http://ex.org/s2", "http://ex.org/p2", "v2"),
|
||||||
|
],
|
||||||
|
"metadata": {"query": query, "result_format": "construct"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBindingsBackend:
|
||||||
|
"""Fake store_backend whose execute_sparql returns a plain bindings result
|
||||||
|
with no "triples" key at all, matching pre-CONSTRUCT-extension backends."""
|
||||||
|
|
||||||
|
supports_named_graphs = True
|
||||||
|
|
||||||
|
def execute_sparql(self, query, **options):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"bindings": [{"s": {"value": "http://ex.org/s1"}}],
|
||||||
|
"variables": ["s"],
|
||||||
|
"metadata": {"query": query},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteQueryPopulatesTriples(unittest.TestCase):
|
||||||
|
def test_execute_query_populates_triples_from_construct_backend(self):
|
||||||
|
engine = QueryEngine(enable_caching=False, enable_optimization=False)
|
||||||
|
result = engine.execute_query(
|
||||||
|
"CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", FakeConstructBackend()
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result.triples,
|
||||||
|
[
|
||||||
|
("http://ex.org/s1", "http://ex.org/p1", "v1"),
|
||||||
|
("http://ex.org/s2", "http://ex.org/p2", "v2"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_execute_query_defaults_triples_to_empty_list_when_backend_omits_key(self):
|
||||||
|
engine = QueryEngine(enable_caching=False, enable_optimization=False)
|
||||||
|
result = engine.execute_query(
|
||||||
|
"SELECT ?s WHERE { ?s ?p ?o }", FakeBindingsBackend()
|
||||||
|
)
|
||||||
|
self.assertEqual(result.triples, [])
|
||||||
|
# Non-CONSTRUCT behavior otherwise unaffected.
|
||||||
|
self.assertEqual(result.bindings, [{"s": {"value": "http://ex.org/s1"}}])
|
||||||
|
self.assertEqual(result.variables, ["s"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user