Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to Web Algebra are recorded here. The format follows
[Semantic Versioning](https://semver.org/). The normative definition of every
operation is [formal-semantics.md](formal-semantics.md).

## [2.0.4] - Unreleased

### Added

- **`DELETE`** removes the document at `url`, as the Graph Store Protocol's
`DELETE`, and returns the one-row `status`/`url` result like the other
writes. It reads the entity tag with `HEAD` and sends it as `If-Match`, is
an error answered outside 2xx (`404` for a document that does not exist),
and counts as a write to its URI for an enclosing `ForEach`. Its kind is
`destructive`.

## [2.0.3] - 2026-10-29

### Changed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ The system is built around the `Operation` abstract base class that provides:
The operations cover read-write Linked Data, SPARQL queries, URI manipulation, and LinkedDataHub-specific resource creation. Non-exhaustive list:

- Linked Data
- `DELETE`
- `GET`
- `PATCH`
- `POST`
Expand Down
33 changes: 24 additions & 9 deletions formal-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,8 @@ The focus accessors, named after their XSLT/XPath counterparts:

Operations are marked in the catalog as **pure** (no observable effect),
**query** (reads external state: HTTP GET, SPARQL query), or **update**
(writes external state: HTTP POST/PUT/PATCH). `SPARQLString` calls an external
LLM service and is additionally **non-deterministic**, as is `StrUUID`.
(writes external state: HTTP POST/PUT/PATCH/DELETE). `SPARQLString` calls an
external LLM service and is additionally **non-deterministic**, as is `StrUUID`.
`Now` is fixed within one evaluation of a document and differs between
evaluations (§4.2).

Expand Down Expand Up @@ -340,7 +340,7 @@ Failures raise Python exceptions per this table (normative):
| unknown variable in `$name` lookup | `ValueError` |
| focus-item lookup miss, or no focus established | `ValueError` |
| two iterations of one `ForEach` updating the same URI (§3.6) | `ValueError` |
| a write (`POST`, `PUT`, `PATCH`, and the `ldh-*` updates) answered outside 2xx (§4.4) | `ValueError` |
| a write (`POST`, `PUT`, `PATCH`, `DELETE`, and the `ldh-*` updates) answered outside 2xx (§4.4) | `ValueError` |
| `SPARQLString` produced no query of the declared shape, or one of its `context` values is empty (§4.3) | `ValueError` |
| `bindings` of a schema operation without a `subject` variable, or with no rows (§4.6) | `ValueError` |
| `Filter` position < 1 or > length | `ValueError` |
Expand Down Expand Up @@ -534,8 +534,8 @@ acronym (URI, UUID, SPARQL, XHTML) kept whole — `ForEach`, `ResolveURI`,
function's name in PascalCase: `STR` → `Str`, `ENCODE_FOR_URI` →
`EncodeForURI`, `STRUUID` → `StrUUID`, `NOW` → `Now`. Upper case is kept only
for the names of protocol operations, as the protocols spell them: the HTTP
methods (`GET`, `POST`, `PUT`, `PATCH`) and the SPARQL query forms (`SELECT`,
`CONSTRUCT`, `DESCRIBE`). An extension family's operations carry the family's
methods (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) and the SPARQL query forms
(`SELECT`, `CONSTRUCT`, `DESCRIBE`). An extension family's operations carry the family's
prefix (Appendix A) before a PascalCase name. A deprecated alias, where the
catalog lists one, names the same operation and is accepted wherever the name
is.
Expand Down Expand Up @@ -982,10 +982,10 @@ the algebra. A response that is not an RDF representation — an unsupported
media type, a missing `Content-Type`, or a body that does not parse as its
declared RDF type — raises `ValueError` (§3.7).

`POST`, `PUT` and `PATCH` return a single-row `Result` with variables
`status` (`xsd:integer` HTTP status) and `url`. `url` is the URI of the
resource that the write produced: the response's `Location` when it has one,
otherwise the request URI. A relative `Location` is resolved against the
`POST`, `PUT`, `PATCH` and `DELETE` return a single-row `Result` with
variables `status` (`xsd:integer` HTTP status) and `url`. `url` is the URI of
the resource that the write produced (for `DELETE`, the one it removed): the
response's `Location` when it has one, otherwise the request URI. A relative `Location` is resolved against the
request URI (RFC 3986 §5).
A response outside the 2xx range is an error (`ValueError`, §3.7), as an
`xsl:result-document` that cannot be written is: the status and the server's
Expand Down Expand Up @@ -1046,6 +1046,20 @@ Python: def execute(self, url: URIRef, update: Literal) -> Result
JSON: url: URI · update: Literal (SPARQL Update string)
```

**DELETE** — remove a resource. *Update* effect.
```
Abstract: URI → Result
Python: def execute(self, url: URIRef) -> Result
JSON: url: URI
```
As the Graph Store Protocol's `DELETE`: the resource and its RDF
representation are removed. A resource that does not exist is answered
`404`, which is an error like any other non-2xx answer (§3.7): the method's
effect is idempotent (RFC 9110 §9.2.2) but its answer is not, so a program
that removes what may already be gone tests for it first. A `DELETE` is a
write to its URI for the rule of §3.6, so two iterations of one `ForEach`
that delete, or delete and write, the same URI are an error.

### 4.5 Graph operations

**Merge** — union of graphs.
Expand Down Expand Up @@ -1126,6 +1140,7 @@ datatype and object properties) as one graph, each scoped by the same
operation with an argument of the same name, or an omitted argument, is
read from the nested operation: a `PUT` whose `url` holds a `POST` sends
the `POST`'s graph.
- `DELETE` (§4.4) is not implemented.
- Text arguments are whitespace-trimmed, so `" "` is `""` (a `Concat`
separator, a `Replace` replacement).
- The variable environment is threaded only inside sequence constructors
Expand Down
76 changes: 76 additions & 0 deletions src/web_algebra/operations/linked_data/delete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from typing import ClassVar, Any
import logging
from rdflib import URIRef
from mcp import types
from web_algebra.mcp_tool import MCPTool
from web_algebra.client_operation import ClientOperation
from web_algebra.operation import Operation, OperationKind
from rdflib.query import Result


class DELETE(ClientOperation, Operation, MCPTool):
"""
Removes the named graph at a URL using HTTP DELETE.
The URL serves as both the resource identifier and the named graph address in systems with direct graph identification.
Returns the one-row status/url result; a resource that does not exist is answered 404, which is an error.
"""

# removes the whole document, and the client sends the If-Match that lets it through
kind: ClassVar[OperationKind] = OperationKind.DESTRUCTIVE


@classmethod
def description(cls) -> str:
return """Removes the named graph at a URL using HTTP DELETE.
The URL serves as both the resource identifier and the named graph address in systems with direct graph identification.
The document and all of its RDF data are removed.
Returns the HTTP status and the URL removed. A URL that does not exist is answered 404, which is an error.
"""

@classmethod
def inputSchema(cls) -> dict:
return {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL of the document to delete. This should be a valid URL.",
},
},
"required": ["url"],
}

def execute(self, url: URIRef) -> Result:
"""Pure function: DELETE the resource at URL with RDFLib terms"""
if not isinstance(url, URIRef):
raise TypeError(f"DELETE operation expects url to be URIRef, got {type(url)}")

url_str = str(url)
logging.info("Executing DELETE operation with URL: %s", url_str)

response = self.client.delete(url_str)
logging.info("DELETE operation status: %s", response.status)

return self.written_response(response)

def execute_json(self, arguments: dict, variable_stack: list = None) -> Result:
"""JSON execution: process arguments with strict type checking"""
url_data = Operation.process_json(
self.settings, arguments["url"], self.context, variable_stack
)
if not isinstance(url_data, URIRef):
raise TypeError(
f"DELETE operation expects 'url' to be URIRef, got {type(url_data)}"
)

return self.execute(url_data)

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
"""MCP execution: plain args → plain results"""
url = URIRef(arguments["url"])

result = self.execute(url)

# Extract status for MCP response
status_binding = result.bindings[0]["status"]
return [types.TextContent(type="text", text=f"DELETE status: {status_binding}")]
16 changes: 15 additions & 1 deletion src/web_algebra/prompts/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Your output is a **JSON plan** of operation calls, where **operations may be nes

Operations produce RDF terms (URIs, literals), RDF graphs, SPARQL results (tables of rows), and sequences of these.

- A **write** (`POST`, `PUT`, `PATCH` and the `ldh-*` writes) returns a one-row result with `status` (the HTTP status) and `url` (the resource written). Read the URL with `Filter(Filter(<write>, 1), "url")`.
- A **write** (`POST`, `PUT`, `PATCH`, `DELETE` and the `ldh-*` writes) returns a one-row result with `status` (the HTTP status) and `url` (the resource written). Read the URL with `Filter(Filter(<write>, 1), "url")`.
- A write the server refuses (any answer outside 2xx, a redirect included) is an error, and the plan stops there. The exception is `303 See Other`, which reports success: its `Location` is the `url`.
- A **query** over a missing name or an unbound variable is an error, not an empty value.

Expand Down Expand Up @@ -503,6 +503,20 @@ Applies a SPARQL Update to the document at `url`. LinkedDataHub accepts a single
}
```

## DELETE(url: URI) → Result

Removes the document at `url` and everything it holds. A `url` that does not exist is an error (`404`), so do not delete a document the plan may already have removed. Returns the one-row `status`/`url` result.

### Example JSON
```json
{
"@op": "DELETE",
"args": {
"url": {"@id": "http://localhost/denmark/copenhagen/"}
}
}
```

## Merge(graphs: Sequence Graph) → Graph

The union of several graphs (JSON-LD or graphs from other operations).
Expand Down
26 changes: 0 additions & 26 deletions tests/test_client_writes.py

This file was deleted.

7 changes: 7 additions & 0 deletions tests/test_operation_kind.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,10 @@ def test_an_upsert_is_destructive_but_a_create_is_not():
assert PUT.kind is DESTRUCTIVE
assert CreateContainer.kind is WRITE
assert CreateItem.kind is WRITE


def test_a_delete_is_destructive():
# DELETE removes the whole document at a URL
from web_algebra.operations.linked_data.delete import DELETE

assert DELETE.kind is DESTRUCTIVE
2 changes: 1 addition & 1 deletion tests/test_operation_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# parametrized over the registry, so it is filled before collection
register(list_operation_subclasses(web_algebra.operations, Operation))

PROTOCOL = {"GET", "POST", "PUT", "PATCH", "SELECT", "CONSTRUCT", "DESCRIBE"}
PROTOCOL = {"GET", "POST", "PUT", "PATCH", "DELETE", "SELECT", "CONSTRUCT", "DESCRIBE"}
PASCAL = r"(?:[A-Z][a-z0-9]+|URI|UUID|SPARQL|XHTML)+"
NAME = re.compile(rf"(?:ldh-)?{PASCAL}")

Expand Down
132 changes: 132 additions & 0 deletions tests/unit/test_delete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Spec: formal-semantics.md "DELETE - remove a resource"
Abstract: URI → Result
Python: def execute(self, url: URIRef) -> Result
- §4.4: returns a single-row Result (status, url); the shared write
contract (url from Location, non-2xx → ValueError, If-Match via HEAD) is
covered for POST, PUT, PATCH and DELETE in test_write_contract.py.
- §4.4: a resource that does not exist is answered 404, an error (§3.7).
- §3.6: a DELETE is a write to its URI; two iterations of one ForEach that
delete, or delete and write, the same URI are an error.

HTTP is stubbed at the urllib boundary (tests/http_stub.py); no network.
"""

from __future__ import annotations

import pytest
from rdflib import Literal, URIRef

from tests.http_stub import StubResponse, default_handler
from web_algebra.operation import Operation

DOC = "http://example.org/doc"


def _delete(url):
return {"@op": "DELETE", "args": {"url": url}}


class TestDELETEPure:
def test_wrong_url_type_raises(self, settings):
op = Operation.get("DELETE")(settings=settings)
with pytest.raises(TypeError):
op.execute(Literal(DOC))

def test_url_is_the_removed_uri(self, settings, http_stub):
# §4.4: url is the URI of the resource the write removed
result = Operation.get("DELETE")(settings=settings).execute(URIRef(DOC))
[row] = list(result)
assert row["url"] == URIRef(DOC)
assert [r.full_url for r in http_stub.with_method("DELETE")] == [DOC]

def test_sends_no_body(self, settings, http_stub):
# §4.4: DELETE takes a URI only, no graph or update
Operation.get("DELETE")(settings=settings).execute(URIRef(DOC))
[request] = http_stub.with_method("DELETE")
assert not request.data


class TestDELETEMissing:
def test_404_is_an_error(self, settings, http_stub):
# §4.4/§3.7: a resource that does not exist is answered 404, a
# non-2xx answer, so an error
def handler(request):
if request.get_method() in ("HEAD", "DELETE"):
return StubResponse(404)
return default_handler(request)

http_stub.handler = handler
with pytest.raises(ValueError):
Operation.get("DELETE")(settings=settings).execute(URIRef(DOC))


class TestDELETEJson:
def test_url_is_a_uri(self, settings, http_stub):
# §4.4 JSON: url: URI
result = Operation.process_json(settings, _delete({"@id": DOC}))
[row] = list(result)
assert row["url"] == URIRef(DOC)

def test_a_string_url_is_a_type_error(self, settings, http_stub):
# §2.2: a plain string is a literal, never a URI
with pytest.raises(TypeError):
Operation.process_json(settings, _delete(DOC))
assert http_stub.with_method("DELETE") == []

def test_missing_url_is_a_key_error(self, settings):
# §3.7: missing required argument key → KeyError
with pytest.raises(KeyError):
Operation.process_json(settings, {"@op": "DELETE", "args": {}})


class TestDELETEInForEach:
def test_two_iterations_deleting_the_same_uri_raise(self, settings, http_stub):
# §3.6/§3.7: a DELETE is a write to its URI
with pytest.raises(ValueError):
Operation.process_json(
settings,
{"@op": "ForEach", "args": {"select": ["a", "b"], "operation": _delete({"@id": DOC})}},
)

def test_delete_and_put_of_the_same_uri_in_two_iterations_raise(self, settings, http_stub):
# §3.6: the rule is stated on targets, not methods. Row 1 deletes DOC,
# row 2 puts DOC, each iteration writing a distinct URI otherwise.
rows = {
"@op": "SELECT",
"args": {
"graph": {"@id": "http://example.org/x", "http://example.org/p": "v"},
"query": f"SELECT ?del ?put {{ VALUES (?del ?put) {{ (<{DOC}> <{DOC}/b>) (<{DOC}/c> <{DOC}>) }} }}",
},
}
operation = [
_delete({"@op": "Value", "args": {"name": "del"}}),
{
"@op": "PUT",
"args": {
"url": {"@op": "Value", "args": {"name": "put"}},
"data": {"@id": DOC, "http://example.org/p": "v"},
},
},
]
with pytest.raises(ValueError):
Operation.process_json(
settings, {"@op": "ForEach", "args": {"select": rows, "operation": operation}}
)

def test_a_put_then_delete_in_one_iteration_is_allowed(self, settings, http_stub):
# §3.6: within one iteration the sequence form orders the writes
result = Operation.process_json(
settings,
{
"@op": "ForEach",
"args": {
"select": ["a"],
"operation": [
{"@op": "PUT", "args": {"url": {"@id": DOC}, "data": {"@id": DOC, "http://example.org/p": "v"}}},
_delete({"@id": DOC}),
],
},
},
)
assert len(result) == 2
assert [r.get_method() for r in http_stub.requests if r.get_method() != "HEAD"] == ["PUT", "DELETE"]
2 changes: 1 addition & 1 deletion tests/unit/test_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Python: def execute(self, url: URIRef, update: Literal) -> Result
- §4.4: returns a single-row Result (status, url); the shared write
contract (url from Location, non-2xx → ValueError, If-Match via HEAD) is
covered for POST, PUT and PATCH in test_write_contract.py.
covered for POST, PUT, PATCH and DELETE in test_write_contract.py.
"""

from __future__ import annotations
Expand Down
Loading
Loading