diff --git a/.tasks/pmp/README.md b/.tasks/pmp/README.md new file mode 100644 index 0000000..b070ec4 --- /dev/null +++ b/.tasks/pmp/README.md @@ -0,0 +1,507 @@ +# Prefix Management Protocol (PMP) Implementation Tasks + +**Status:** Not Started +**Priority:** Medium +**Total Estimated Time:** 1.5 weeks (7-10 days) + +--- + +## Overview + +This directory contains task breakdowns for implementing the [Prefix Management Protocol (PMP)](../../protocol/Prefix_Management_Protocol.md) in CHUCC Server. The implementation enables IDE integration, RDF/XML namespace preservation, and SPARQL query template generation. + +**Protocol Documents:** +- [Prefix Management Protocol v1.0](../../protocol/Prefix_Management_Protocol.md) - Generic base protocol +- [CHUCC Implementation Guide](../../docs/api/prefix-management.md) - Version-aware implementation + +--- + +## Why Prefix Management? + +### Problem +When users import RDF/XML files with namespace declarations like: +```xml + + + +``` + +Those prefixes are **lost** after import. Users must manually retype them for every SPARQL query: +```sparql +PREFIX foaf: +PREFIX schema: + +SELECT ?name WHERE { + ?person foaf:name ?name . +} +``` + +### Solution +Store prefixes in version control (as part of commits via RDFPatch PA/PD directives). SPARQL editors can then: +1. Fetch prefixes: `GET /version/datasets/{name}/branches/{branch}/prefixes` +2. Auto-insert PREFIX declarations into query template +3. Save user time and reduce errors + +**Key Insight:** Prefixes are **already versioned** via RDFPatch PA/PD directives. We just need REST API to expose them. + +--- + +## Architecture Overview + +### No New Events Needed! + +Prefix changes create commits using **existing** `CommitCreatedEvent`: + +```java +PUT /prefixes +↓ +UpdatePrefixesCommandHandler generates RDFPatch with PA/PD directives +↓ +CreateCommitCommandHandler creates CommitCreatedEvent +↓ +ReadModelProjector applies patch (including PA/PD) +↓ +Prefix map updated in materialized branch +``` + +### Components to Create + +1. **Command Handler**: `UpdatePrefixesCommandHandler` + - Generates RDFPatch with PA/PD directives + - Delegates to `CreateCommitCommandHandler` + +2. **REST Controller**: `PrefixManagementController` + - GET/PUT/PATCH/DELETE operations + - Time-travel queries + - Suggested prefixes + +3. **Service**: `PrefixSuggestionService` (optional) + - Analyzes dataset for common namespaces + - Matches against conventional prefixes + +4. **DTOs**: Request/response objects + - `UpdatePrefixesRequest` + - `PrefixResponse` + - `SuggestedPrefixesResponse` + +--- + +## Task Breakdown + +### Session 1: Core Implementation (4-5 hours) ⭐ START HERE +**File:** [session-1-core-implementation.md](./session-1-core-implementation.md) + +**Endpoints:** +- ✅ `GET /version/datasets/{name}/branches/{branch}/prefixes` +- ✅ `PUT /version/datasets/{name}/branches/{branch}/prefixes` +- ✅ `PATCH /version/datasets/{name}/branches/{branch}/prefixes` +- ✅ `DELETE /version/datasets/{name}/branches/{branch}/prefixes?prefix=...` + +**Deliverables:** +- UpdatePrefixesCommandHandler +- PrefixManagementController (basic operations) +- DTOs (UpdatePrefixesRequest, PrefixResponse) +- Integration tests (10+ tests) +- Unit tests for handler + +--- + +### Session 2: Time-Travel Support (2-3 hours) +**File:** [session-2-time-travel-support.md](./session-2-time-travel-support.md) + +**Endpoints:** +- ✅ `GET /version/datasets/{name}/commits/{id}/prefixes` + +**Deliverables:** +- Time-travel endpoint in PrefixManagementController +- Integration with MaterializedViewRebuildService +- Performance testing (cache hits vs rebuilds) +- 5+ integration tests + +--- + +### Session 3: Suggested Prefixes (2-3 hours) +**File:** [session-3-suggested-prefixes.md](./session-3-suggested-prefixes.md) + +**Endpoints:** +- ✅ `GET /version/datasets/{name}/branches/{branch}/prefixes/suggested` + +**Deliverables:** +- PrefixSuggestionService +- Namespace analysis algorithm +- Conventional prefix database (prefix.cc subset) +- 8+ integration tests + +--- + +### Session 4: OpenAPI and Comprehensive Testing (2-3 hours) +**File:** [session-4-openapi-and-tests.md](./session-4-openapi-and-tests.md) + +**Deliverables:** +- OpenAPI documentation (prefixes endpoints) +- Error handling tests (validation, 404s, 403s) +- Branch protection integration tests +- Cross-protocol tests (prefixes + GSP + merge) +- Documentation examples + +--- + +### Session 5: Merge Conflict Handling (Future - 3-4 hours) +**File:** [session-5-merge-conflict-handling.md](./session-5-merge-conflict-handling.md) + +**Status:** Optional Enhancement + +**Deliverables:** +- Enhanced conflict detection for prefix conflicts +- Conflict resolution UI support +- MergeCommandHandler updates +- Conflict resolution tests + +--- + +## Dependencies + +### Must Be Completed First +- ✅ CQRS + Event Sourcing architecture (COMPLETED) +- ✅ Materialized branch views (COMPLETED) +- ✅ CreateCommitCommandHandler (COMPLETED) +- ✅ RDFPatch integration (COMPLETED) + +### Can Be Done In Parallel +- SPARQL Protocol endpoints (independent feature) +- Additional version control features (tags, etc.) + +--- + +## Success Criteria + +### Functional Requirements +- ✅ All PMP endpoints implemented (no 501 stubs) +- ✅ Prefix changes create commits (version controlled) +- ✅ Time-travel works (query prefixes at any commit) +- ✅ Suggested prefixes help users discover namespaces +- ✅ Merge automatically handles prefix changes + +### Quality Requirements +- ✅ All tests pass (~30+ new tests total) +- ✅ Zero quality violations (Checkstyle, SpotBugs, PMD, compiler warnings) +- ✅ Full build passes: `mvn -q clean install` +- ✅ OpenAPI documentation complete +- ✅ Integration tests cover edge cases (conflicts, validation, 404s) + +### Performance Requirements +- ✅ GET prefixes: <10ms (materialized branch cache) +- ✅ PUT/PATCH/DELETE: <100ms (commit creation) +- ✅ Time-travel: <1s (typical for uncached rebuild) +- ✅ Suggested prefixes: <500ms (dataset scan) + +--- + +## Implementation Strategy + +### Phase 1: Minimal Viable Product (Session 1) +**Goal:** Basic GET/PUT/PATCH/DELETE working + +**Deliverables:** +- Core CRUD operations +- Command handler (reuses CreateCommitCommandHandler) +- REST controller +- Integration tests + +**Estimated Time:** 4-5 hours + +--- + +### Phase 2: Enhanced Features (Sessions 2-3) +**Goal:** Time-travel and suggestions + +**Deliverables:** +- Commit-based queries +- Namespace discovery +- Prefix suggestions + +**Estimated Time:** 4-6 hours + +--- + +### Phase 3: Production Readiness (Session 4) +**Goal:** Documentation and comprehensive testing + +**Deliverables:** +- OpenAPI docs +- Error handling +- Cross-protocol tests +- Performance validation + +**Estimated Time:** 2-3 hours + +--- + +### Phase 4: Advanced Features (Session 5 - Optional) +**Goal:** Merge conflict handling + +**Deliverables:** +- Enhanced conflict detection +- Resolution strategies +- Conflict UI support + +**Estimated Time:** 3-4 hours (OPTIONAL) + +--- + +## Testing Strategy + +### Integration Tests (Primary) +**Pattern:** API layer tests with projector **DISABLED** + +```java +@SpringBootTest(webEnvironment = RANDOM_PORT) +@ActiveProfiles("it") +class PrefixManagementIT extends IntegrationTestFixture { + // Projector disabled by default + + @Test + void putPrefixes_shouldReturn201Created() { + // Test HTTP contract only + ResponseEntity response = restTemplate.exchange(...); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + // Note: Repository updates handled by ReadModelProjector (disabled) + } +} +``` + +**Rationale:** Test command side (HTTP API), not query side (projector). + +### Unit Tests (Secondary) +Test command handler logic: + +```java +@Test +void buildPrefixPatch_shouldGeneratePaDirectives() { + RDFPatch patch = handler.buildPrefixPatch(oldPrefixes, newPrefixes, Operation.PATCH); + assertThat(patch.toString()).contains("PA foaf: "); +} +``` + +### Projector Tests (Optional) +Only if testing actual projection: + +```java +@TestPropertySource(properties = "projector.kafka-listener.enabled=true") +class PrefixProjectionIT { + @Test + void commitWithPrefixes_shouldUpdateMaterializedBranch() { + // Publish event, wait for projection + await().untilAsserted(() -> { + DatasetGraph dsg = materializedBranchRepository.getMaterializedBranch(...); + assertThat(dsg.getDefaultGraph().getPrefixMapping().getNsURIPrefix(...)).isNotNull(); + }); + } +} +``` + +--- + +## Common Pitfalls + +### ❌ Mistake 1: Creating New Events +**Wrong:** +```java +PrefixChangedEvent event = new PrefixChangedEvent(...); +eventPublisher.publish(event); +``` + +**Right:** +```java +// Generate RDFPatch with PA/PD directives +RDFPatch patch = buildPrefixPatch(...); + +// Reuse existing commit creation +CreateCommitCommand cmd = new CreateCommitCommand(..., patch); +createCommitCommandHandler.handle(cmd); +``` + +--- + +### ❌ Mistake 2: Direct Repository Writes +**Wrong:** +```java +PrefixMapping pm = dsg.getDefaultGraph().getPrefixMapping(); +pm.setNsPrefix("foaf", "http://..."); // Bypasses event sourcing! +``` + +**Right:** +```java +// Let projector handle updates via PA/PD directives +RDFPatch patch = RDFPatchBuilder.create() + .txnBegin() + .prefixAdd("foaf", "http://...") + .txnCommit() + .build(); +``` + +--- + +### ❌ Mistake 3: Forgetting SPARQL-VC-Author Header +**Wrong:** +```http +PUT /prefixes +{ "prefixes": {...} } + +→ 400 Bad Request (missing author) +``` + +**Right:** +```http +PUT /prefixes +SPARQL-VC-Author: Alice + +{ "prefixes": {...} } + +→ 201 Created +``` + +--- + +## Development Workflow + +### Before Starting +1. ✅ Read [base protocol](../../protocol/Prefix_Management_Protocol.md) +2. ✅ Read [implementation guide](../../docs/api/prefix-management.md) +3. ✅ Review existing commit handlers for patterns +4. ✅ Check RDFPatch PA/PD directive documentation + +### During Implementation +1. ✅ Write tests first (TDD) +2. ✅ Use `-q` for all Maven commands +3. ✅ Run static analysis before tests: `mvn -q compile checkstyle:check` +4. ✅ Test incrementally (don't wait until end) +5. ✅ Invoke `@cqrs-compliance-checker` after completing handler + +### After Each Session +1. ✅ Run full build: `mvn -q clean install` +2. ✅ Verify zero quality violations +3. ✅ Create conventional commit message +4. ✅ Update session status in this README + +### After All Sessions +1. ✅ Delete completed task files +2. ✅ Update main [task roadmap](../README.md) +3. ✅ Mark feature as completed + +--- + +## IDE Integration Example + +**Goal:** SPARQL editor auto-inserts prefixes + +```javascript +// Fetch prefixes from CHUCC +const response = await fetch( + 'http://chucc/version/datasets/mydata/branches/main/prefixes' +); +const { prefixes } = await response.json(); + +// Generate PREFIX block +const prefixBlock = Object.entries(prefixes) + .map(([prefix, iri]) => `PREFIX ${prefix}: <${iri}>`) + .join('\n'); + +// Insert into editor +editor.insertText(` +${prefixBlock} + +SELECT * WHERE { + ?s ?p ?o . +} +LIMIT 10 +`); +``` + +--- + +## Performance Optimization + +### Caching Strategy +Prefixes are cached as part of **materialized branches**: +- ✅ No separate prefix cache needed +- ✅ LRU eviction handles memory (default: 100 branches) +- ✅ Rebuild on-demand if evicted (~1s typical) + +### Query Performance +```java +// O(1) lookup - just read prefix mapping +PrefixMapping pm = dsg.getDefaultGraph().getPrefixMapping(); +Map prefixes = pm.getNsPrefixMap(); +``` + +**Result:** <1ms for GET requests (in-memory hash map) + +--- + +## Security Considerations + +### Authorization +Prefix modifications require **same permissions** as graph modifications: +- Read prefixes → Read permission +- Modify prefixes → Write permission + +### Audit Trail +All prefix changes are **auditable**: +- ✅ Stored in Kafka (permanent log) +- ✅ Commit metadata includes author and timestamp +- ✅ Can query: "Who changed the foaf prefix and when?" + +--- + +## References + +### Protocol Documents +- [Prefix Management Protocol v1.0](../../protocol/Prefix_Management_Protocol.md) +- [CHUCC Implementation Guide](../../docs/api/prefix-management.md) + +### Architecture Guides +- [CQRS + Event Sourcing](../../docs/architecture/cqrs-event-sourcing.md) +- [Development Guidelines](../../.claude/CLAUDE.md) + +### RDFPatch Documentation +- [RDFPatch Specification](https://afs.github.io/rdf-patch/) +- [PA/PD Directive Details](https://afs.github.io/rdf-patch/#prefix-directives) + +### Related Code +- `CreateCommitCommandHandler.java` - Commit creation pattern +- `InMemoryMaterializedBranchRepository.java` - Where prefixes are stored +- `ReadModelProjector.java` - Where PA/PD directives are applied + +--- + +## Progress Tracking + +| Session | Status | Estimated | Actual | Notes | +|---------|--------|-----------|--------|-------| +| 1: Core Implementation | ⏳ Not Started | 4-5h | - | GET/PUT/PATCH/DELETE | +| 2: Time-Travel | ⏳ Not Started | 2-3h | - | Commit-based queries | +| 3: Suggested Prefixes | ⏳ Not Started | 2-3h | - | Namespace analysis | +| 4: OpenAPI & Tests | ⏳ Not Started | 2-3h | - | Docs + comprehensive tests | +| 5: Merge Conflicts | ⏸️ Deferred | 3-4h | - | Optional enhancement | + +**Total Progress:** 0% (0/4 core sessions completed) + +--- + +## Questions? + +- Read session task files for detailed implementation steps +- Check protocol specifications for requirements +- Review [implementation guide](../../docs/api/prefix-management.md) for examples +- Consult [development guidelines](../../.claude/CLAUDE.md) for best practices +- Ask about CQRS patterns if unsure + +--- + +**Status:** Ready to start +**Next Step:** Begin [Session 1: Core Implementation](./session-1-core-implementation.md) +**Last Updated:** 2025-11-06 diff --git a/.tasks/pmp/session-1-core-implementation.md b/.tasks/pmp/session-1-core-implementation.md new file mode 100644 index 0000000..64e7ceb --- /dev/null +++ b/.tasks/pmp/session-1-core-implementation.md @@ -0,0 +1,1088 @@ +# Session 1: Core Prefix Management Implementation + +**Status:** Not Started +**Estimated Time:** 4-5 hours +**Priority:** High (Start Here) +**Dependencies:** None (all infrastructure exists) + +--- + +## Overview + +Implement basic GET/PUT/PATCH/DELETE operations for prefix management. This session provides the foundation for all other prefix management features. + +**Goal:** Users can retrieve, replace, add, and delete prefixes via REST API. All operations create commits (version controlled). + +--- + +## Current State + +**Existing Infrastructure:** +- ✅ `CreateCommitCommandHandler` - Creates commits with RDFPatch +- ✅ `MaterializedBranchRepository` - Caches branch HEAD (includes PrefixMapping) +- ✅ `ReadModelProjector` - Applies PA/PD directives from RDFPatch +- ✅ RDFPatch support - PA (Prefix Add) and PD (Prefix Delete) directives + +**What exists (empty stub):** +```java +@RestController +@RequestMapping("/version/datasets/{dataset}") +public class PrefixManagementController { + // Currently returns 501 Not Implemented +} +``` + +--- + +## Requirements + +### Endpoints to Implement + +#### 1. GET - Retrieve Prefixes +```http +GET /version/datasets/{dataset}/branches/{branch}/prefixes +Accept: application/json + +→ 200 OK +{ + "dataset": "mydata", + "branch": "main", + "commitId": "01JCDN...", + "prefixes": { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "foaf": "http://xmlns.com/foaf/0.1/" + } +} +``` + +#### 2. PUT - Replace Entire Prefix Map +```http +PUT /version/datasets/{dataset}/branches/{branch}/prefixes +Content-Type: application/json +SPARQL-VC-Author: Alice + +{ + "message": "Update prefixes", + "prefixes": { + "ex": "http://example.org/", + "schema": "http://schema.org/" + } +} + +→ 201 Created +Location: /version/datasets/mydata/commits/01JCDN... +ETag: "01JCDN..." + +{ + "dataset": "mydata", + "branch": "main", + "commitId": "01JCDN...", + "message": "Update prefixes" +} +``` + +#### 3. PATCH - Add/Update Selected Prefixes +```http +PATCH /version/datasets/{dataset}/branches/{branch}/prefixes +Content-Type: application/json +SPARQL-VC-Author: Bob + +{ + "message": "Add geospatial prefixes", + "prefixes": { + "geo": "http://www.opengis.net/ont/geosparql#", + "sf": "http://www.opengis.net/ont/sf#" + } +} + +→ 201 Created +{ + "commitId": "01JCDN...", + "message": "Add geospatial prefixes" +} +``` + +#### 4. DELETE - Remove Prefixes +```http +DELETE /version/datasets/{dataset}/branches/{branch}/prefixes?prefix=temp&prefix=test +SPARQL-VC-Author: Alice + +→ 201 Created +{ + "commitId": "01JCDN...", + "message": "Remove prefixes: temp, test" +} +``` + +--- + +## Implementation Steps + +### Step 1: Create DTOs (30 minutes) + +**1.1 Create `PrefixResponse.java`** + +```java +package org.chucc.vcserver.dto; + +import java.util.Map; + +/** + * Response containing prefix mappings for a branch or commit. + */ +public record PrefixResponse( + String dataset, + String branch, // Nullable for commit-based queries + String commitId, + Map prefixes +) { + /** + * Creates a prefix response. + * + * @param dataset the dataset name + * @param branch the branch name (nullable for commit queries) + * @param commitId the commit ID + * @param prefixes the prefix mappings (prefix name → IRI) + */ + public PrefixResponse { + // Defensive copy + prefixes = Map.copyOf(prefixes); + } +} +``` + +**1.2 Create `UpdatePrefixesRequest.java`** + +```java +package org.chucc.vcserver.dto; + +import jakarta.validation.constraints.NotNull; +import java.util.Map; + +/** + * Request to update prefix mappings. + */ +public record UpdatePrefixesRequest( + String message, // Optional commit message + + @NotNull(message = "Prefixes map is required") + Map prefixes +) { + /** + * Creates an update prefixes request. + * + * @param message optional commit message + * @param prefixes prefix mappings to add/replace/delete + */ + public UpdatePrefixesRequest { + // Defensive copy + prefixes = Map.copyOf(prefixes); + } +} +``` + +**1.3 Create `CommitResponse.java`** (reusable DTO) + +```java +package org.chucc.vcserver.dto; + +import org.chucc.vcserver.domain.CommitMetadata; + +/** + * Response after creating a commit. + */ +public record CommitResponse( + String dataset, + String branch, + String commitId, + String message, + String author, + String timestamp +) { + /** + * Creates a commit response from commit metadata. + * + * @param dataset the dataset name + * @param branch the branch name + * @param commit the commit metadata + * @return the commit response + */ + public static CommitResponse from(String dataset, String branch, CommitMetadata commit) { + return new CommitResponse( + dataset, + branch, + commit.id(), + commit.message(), + commit.author(), + commit.timestamp().toString() + ); + } +} +``` + +--- + +### Step 2: Create Command and Handler (90 minutes) + +**2.1 Create `UpdatePrefixesCommand.java`** + +```java +package org.chucc.vcserver.command; + +import java.util.Map; +import java.util.Optional; + +/** + * Command to update prefix mappings on a branch. + */ +public record UpdatePrefixesCommand( + String dataset, + String branch, + String author, + Map newPrefixes, + Operation operation, + Optional message +) { + /** + * Operation type for prefix updates. + */ + public enum Operation { + PUT, // Replace all prefixes + PATCH, // Add/update selected prefixes + DELETE // Remove selected prefixes + } + + /** + * Creates an update prefixes command. + * + * @param dataset the dataset name + * @param branch the branch name + * @param author the commit author + * @param newPrefixes the prefix mappings + * @param operation the operation type + * @param message optional commit message + */ + public UpdatePrefixesCommand { + newPrefixes = Map.copyOf(newPrefixes); + } +} +``` + +**2.2 Create `UpdatePrefixesCommandHandler.java`** + +```java +package org.chucc.vcserver.command; + +import java.util.Map; +import org.apache.jena.rdfpatch.RDFPatch; +import org.apache.jena.rdfpatch.RDFPatchOps; +import org.apache.jena.sparql.core.DatasetGraph; +import org.chucc.vcserver.domain.CommitMetadata; +import org.chucc.vcserver.exception.BranchNotFoundException; +import org.chucc.vcserver.repository.BranchRepository; +import org.chucc.vcserver.repository.MaterializedBranchRepository; +import org.springframework.stereotype.Component; + +/** + * Handles prefix update commands by generating RDFPatch with PA/PD directives. + * + *

This handler delegates to CreateCommitCommandHandler - no new events needed. + */ +@Component +public class UpdatePrefixesCommandHandler { + + private final MaterializedBranchRepository materializedBranchRepository; + private final BranchRepository branchRepository; + private final CreateCommitCommandHandler createCommitCommandHandler; + + /** + * Creates an update prefixes command handler. + * + * @param materializedBranchRepository the materialized branch repository + * @param branchRepository the branch repository + * @param createCommitCommandHandler the commit creation handler + */ + public UpdatePrefixesCommandHandler( + MaterializedBranchRepository materializedBranchRepository, + BranchRepository branchRepository, + CreateCommitCommandHandler createCommitCommandHandler) { + this.materializedBranchRepository = materializedBranchRepository; + this.branchRepository = branchRepository; + this.createCommitCommandHandler = createCommitCommandHandler; + } + + /** + * Handles prefix update command. + * + * @param cmd the update prefixes command + * @return commit metadata + * @throws BranchNotFoundException if branch doesn't exist + */ + public CommitMetadata handle(UpdatePrefixesCommand cmd) { + // 1. Validate branch exists + branchRepository.findByDatasetAndName(cmd.dataset(), cmd.branch()) + .orElseThrow(() -> new BranchNotFoundException(cmd.dataset(), cmd.branch())); + + // 2. Get current prefixes from materialized branch + DatasetGraph currentDsg = materializedBranchRepository + .getMaterializedBranch(cmd.dataset(), cmd.branch()); + Map oldPrefixes = currentDsg.getDefaultGraph() + .getPrefixMapping() + .getNsPrefixMap(); + + // 3. Generate RDFPatch with PA/PD directives + RDFPatch patch = buildPrefixPatch(oldPrefixes, cmd.newPrefixes(), cmd.operation()); + + // 4. Create commit via existing handler + String message = cmd.message().orElseGet(() -> generateDefaultMessage(cmd)); + CreateCommitCommand commitCmd = new CreateCommitCommand( + cmd.dataset(), + cmd.branch(), + message, + cmd.author(), + RDFPatchOps.str(patch) + ); + + return createCommitCommandHandler.handle(commitCmd); + } + + /** + * Builds RDFPatch with PA/PD directives. + * + * @param oldPrefixes current prefix mappings + * @param newPrefixes new prefix mappings + * @param operation the operation type + * @return RDFPatch with prefix directives + */ + RDFPatch buildPrefixPatch( + Map oldPrefixes, + Map newPrefixes, + UpdatePrefixesCommand.Operation operation) { + + StringBuilder patchStr = new StringBuilder("TX .\n"); + + switch (operation) { + case PUT -> { + // Remove all old prefixes + oldPrefixes.forEach((prefix, iri) -> + patchStr.append("PD ").append(prefix).append(": .\n")); + // Add all new prefixes + newPrefixes.forEach((prefix, iri) -> + patchStr.append("PA ").append(prefix).append(": <").append(iri).append("> .\n")); + } + case PATCH -> { + // Add/update only specified prefixes + newPrefixes.forEach((prefix, iri) -> + patchStr.append("PA ").append(prefix).append(": <").append(iri).append("> .\n")); + } + case DELETE -> { + // Remove specified prefixes + newPrefixes.keySet().forEach(prefix -> + patchStr.append("PD ").append(prefix).append(": .\n")); + } + } + + patchStr.append("TC ."); + return RDFPatchOps.read(patchStr.toString()); + } + + /** + * Generates default commit message. + * + * @param cmd the command + * @return default message + */ + private String generateDefaultMessage(UpdatePrefixesCommand cmd) { + return switch (cmd.operation()) { + case PUT -> "Replace prefix map"; + case PATCH -> "Add prefixes: " + String.join(", ", cmd.newPrefixes().keySet()); + case DELETE -> "Remove prefixes: " + String.join(", ", cmd.newPrefixes().keySet()); + }; + } +} +``` + +--- + +### Step 3: Create REST Controller (60 minutes) + +**3.1 Create `PrefixManagementController.java`** + +```java +package org.chucc.vcserver.controller; + +import jakarta.validation.Valid; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.jena.sparql.core.DatasetGraph; +import org.chucc.vcserver.command.UpdatePrefixesCommand; +import org.chucc.vcserver.command.UpdatePrefixesCommand.Operation; +import org.chucc.vcserver.command.UpdatePrefixesCommandHandler; +import org.chucc.vcserver.domain.Branch; +import org.chucc.vcserver.domain.CommitMetadata; +import org.chucc.vcserver.dto.CommitResponse; +import org.chucc.vcserver.dto.PrefixResponse; +import org.chucc.vcserver.dto.UpdatePrefixesRequest; +import org.chucc.vcserver.exception.BranchNotFoundException; +import org.chucc.vcserver.repository.BranchRepository; +import org.chucc.vcserver.repository.MaterializedBranchRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller for Prefix Management Protocol (PMP). + * + *

Implements version-aware prefix management with commit creation. + * + * @see PMP Specification + */ +@RestController +@RequestMapping("/version/datasets/{dataset}") +public class PrefixManagementController { + + private final MaterializedBranchRepository materializedBranchRepository; + private final BranchRepository branchRepository; + private final UpdatePrefixesCommandHandler updatePrefixesCommandHandler; + + /** + * Creates a prefix management controller. + * + * @param materializedBranchRepository the materialized branch repository + * @param branchRepository the branch repository + * @param updatePrefixesCommandHandler the prefix update handler + */ + public PrefixManagementController( + MaterializedBranchRepository materializedBranchRepository, + BranchRepository branchRepository, + UpdatePrefixesCommandHandler updatePrefixesCommandHandler) { + this.materializedBranchRepository = materializedBranchRepository; + this.branchRepository = branchRepository; + this.updatePrefixesCommandHandler = updatePrefixesCommandHandler; + } + + /** + * Retrieves prefix mappings for a branch. + * + * @param dataset the dataset name + * @param branch the branch name + * @return prefix response + * @throws BranchNotFoundException if branch doesn't exist + */ + @GetMapping("/branches/{branch}/prefixes") + public ResponseEntity getCurrentPrefixes( + @PathVariable String dataset, + @PathVariable String branch) { + + // Get branch (validate exists) + Branch branchObj = branchRepository + .findByDatasetAndName(dataset, branch) + .orElseThrow(() -> new BranchNotFoundException(dataset, branch)); + + // Read prefixes from materialized branch + DatasetGraph dsg = materializedBranchRepository + .getMaterializedBranch(dataset, branch); + Map prefixes = dsg.getDefaultGraph() + .getPrefixMapping() + .getNsPrefixMap(); + + PrefixResponse response = new PrefixResponse( + dataset, + branch, + branchObj.headCommitId(), + prefixes + ); + + return ResponseEntity + .ok() + .eTag(branchObj.headCommitId()) + .body(response); + } + + /** + * Replaces entire prefix map (creates commit). + * + * @param dataset the dataset name + * @param branch the branch name + * @param author the commit author + * @param request the update request + * @return commit response + */ + @PutMapping("/branches/{branch}/prefixes") + public ResponseEntity replacePrefixes( + @PathVariable String dataset, + @PathVariable String branch, + @RequestHeader("SPARQL-VC-Author") String author, + @Valid @RequestBody UpdatePrefixesRequest request) { + + UpdatePrefixesCommand cmd = new UpdatePrefixesCommand( + dataset, + branch, + author, + request.prefixes(), + Operation.PUT, + Optional.ofNullable(request.message()) + ); + + CommitMetadata commit = updatePrefixesCommandHandler.handle(cmd); + + URI location = URI.create( + "/version/datasets/" + dataset + "/commits/" + commit.id() + ); + + CommitResponse response = CommitResponse.from(dataset, branch, commit); + + return ResponseEntity + .created(location) + .eTag(commit.id()) + .body(response); + } + + /** + * Adds or updates selected prefixes (creates commit). + * + * @param dataset the dataset name + * @param branch the branch name + * @param author the commit author + * @param request the update request + * @return commit response + */ + @PatchMapping("/branches/{branch}/prefixes") + public ResponseEntity updatePrefixes( + @PathVariable String dataset, + @PathVariable String branch, + @RequestHeader("SPARQL-VC-Author") String author, + @Valid @RequestBody UpdatePrefixesRequest request) { + + UpdatePrefixesCommand cmd = new UpdatePrefixesCommand( + dataset, + branch, + author, + request.prefixes(), + Operation.PATCH, + Optional.ofNullable(request.message()) + ); + + CommitMetadata commit = updatePrefixesCommandHandler.handle(cmd); + + URI location = URI.create( + "/version/datasets/" + dataset + "/commits/" + commit.id() + ); + + CommitResponse response = CommitResponse.from(dataset, branch, commit); + + return ResponseEntity + .created(location) + .eTag(commit.id()) + .body(response); + } + + /** + * Removes specified prefixes (creates commit). + * + * @param dataset the dataset name + * @param branch the branch name + * @param prefixNames the prefix names to remove + * @param message optional commit message + * @param author the commit author + * @return commit response + */ + @DeleteMapping("/branches/{branch}/prefixes") + public ResponseEntity deletePrefixes( + @PathVariable String dataset, + @PathVariable String branch, + @RequestParam("prefix") List prefixNames, + @RequestParam(required = false) String message, + @RequestHeader("SPARQL-VC-Author") String author) { + + // Convert prefix list to map (value doesn't matter for DELETE) + Map prefixesToDelete = prefixNames.stream() + .collect(java.util.stream.Collectors.toMap(p -> p, p -> "")); + + UpdatePrefixesCommand cmd = new UpdatePrefixesCommand( + dataset, + branch, + author, + prefixesToDelete, + Operation.DELETE, + Optional.ofNullable(message) + ); + + CommitMetadata commit = updatePrefixesCommandHandler.handle(cmd); + + URI location = URI.create( + "/version/datasets/" + dataset + "/commits/" + commit.id() + ); + + CommitResponse response = CommitResponse.from(dataset, branch, commit); + + return ResponseEntity + .created(location) + .eTag(commit.id()) + .body(response); + } +} +``` + +--- + +### Step 4: Write Integration Tests (90 minutes) + +**4.1 Create `PrefixManagementIT.java`** + +```java +package org.chucc.vcserver.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.chucc.vcserver.dto.CommitResponse; +import org.chucc.vcserver.dto.PrefixResponse; +import org.chucc.vcserver.dto.UpdatePrefixesRequest; +import org.chucc.vcserver.testutil.IntegrationTestFixture; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +/** + * Integration tests for Prefix Management Protocol (PMP). + * + *

Tests API layer only (projector disabled by default). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("it") +class PrefixManagementIT extends IntegrationTestFixture { + + @Test + void getPrefixes_shouldReturnEmptyMap_whenNoPrefix() { + // Act + ResponseEntity response = restTemplate.exchange( + "/version/datasets/default/branches/main/prefixes", + HttpMethod.GET, + null, + PrefixResponse.class + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().prefixes()).isEmpty(); + assertThat(response.getHeaders().getETag()).isNotNull(); + } + + @Test + void putPrefixes_shouldReturn201Created() { + // Arrange + UpdatePrefixesRequest request = new UpdatePrefixesRequest( + "Add RDF prefixes", + Map.of( + "rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs", "http://www.w3.org/2000/01/rdf-schema#" + ) + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("SPARQL-VC-Author", "TestUser "); + + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // Act + ResponseEntity response = restTemplate.exchange( + "/version/datasets/default/branches/main/prefixes", + HttpMethod.PUT, + httpEntity, + CommitResponse.class + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().commitId()).isNotNull(); + assertThat(response.getBody().message()).isEqualTo("Add RDF prefixes"); + assertThat(response.getHeaders().getLocation()).isNotNull(); + assertThat(response.getHeaders().getETag()).isNotNull(); + + // Note: Repository updates handled by ReadModelProjector (disabled in this test) + } + + @Test + void patchPrefixes_shouldReturn201Created() { + // Arrange + UpdatePrefixesRequest request = new UpdatePrefixesRequest( + "Add FOAF prefix", + Map.of("foaf", "http://xmlns.com/foaf/0.1/") + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("SPARQL-VC-Author", "TestUser "); + + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // Act + ResponseEntity response = restTemplate.exchange( + "/version/datasets/default/branches/main/prefixes", + HttpMethod.PATCH, + httpEntity, + CommitResponse.class + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().message()).isEqualTo("Add FOAF prefix"); + } + + @Test + void deletePrefixes_shouldReturn201Created() { + // Arrange + HttpHeaders headers = new HttpHeaders(); + headers.set("SPARQL-VC-Author", "TestUser "); + + HttpEntity httpEntity = new HttpEntity<>(headers); + + // Act + ResponseEntity response = restTemplate.exchange( + "/version/datasets/default/branches/main/prefixes?prefix=temp&prefix=test", + HttpMethod.DELETE, + httpEntity, + CommitResponse.class + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().message()).contains("Remove prefixes"); + } + + @Test + void putPrefixes_shouldReturn400_whenAuthorMissing() { + // Arrange + UpdatePrefixesRequest request = new UpdatePrefixesRequest( + null, + Map.of("ex", "http://example.org/") + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + // No SPARQL-VC-Author header + + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + // Act + ResponseEntity response = restTemplate.exchange( + "/version/datasets/default/branches/main/prefixes", + HttpMethod.PUT, + httpEntity, + String.class + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void getPrefixes_shouldReturn404_whenBranchNotFound() { + // Act + ResponseEntity response = restTemplate.exchange( + "/version/datasets/default/branches/nonexistent/prefixes", + HttpMethod.GET, + null, + String.class + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } +} +``` + +Add 4-5 more tests: +- `putPrefixes_shouldReplaceAllPrefixes()` +- `patchPrefixes_shouldPreserveExistingPrefixes()` +- `deletePrefixes_shouldBeIdempotent()` +- `putPrefixes_shouldValidatePrefixNames()` +- `putPrefixes_shouldValidateAbsoluteIris()` + +--- + +### Step 5: Write Unit Tests (30 minutes) + +**5.1 Create `UpdatePrefixesCommandHandlerTest.java`** + +```java +package org.chucc.vcserver.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import java.util.Map; +import java.util.Optional; +import org.apache.jena.rdfpatch.RDFPatch; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for UpdatePrefixesCommandHandler. + */ +class UpdatePrefixesCommandHandlerTest { + + @Test + void buildPrefixPatch_shouldGeneratePaDirectives_forPatchOperation() { + // Arrange + UpdatePrefixesCommandHandler handler = new UpdatePrefixesCommandHandler( + mock(MaterializedBranchRepository.class), + mock(BranchRepository.class), + mock(CreateCommitCommandHandler.class) + ); + + Map oldPrefixes = Map.of(); + Map newPrefixes = Map.of( + "foaf", "http://xmlns.com/foaf/0.1/", + "geo", "http://www.opengis.net/ont/geosparql#" + ); + + // Act + RDFPatch patch = handler.buildPrefixPatch( + oldPrefixes, + newPrefixes, + UpdatePrefixesCommand.Operation.PATCH + ); + + // Assert + String patchStr = patch.toString(); + assertThat(patchStr).contains("PA foaf: "); + assertThat(patchStr).contains("PA geo: "); + assertThat(patchStr).doesNotContain("PD"); // No deletions + } + + @Test + void buildPrefixPatch_shouldGeneratePdThenPa_forPutOperation() { + // Arrange + UpdatePrefixesCommandHandler handler = new UpdatePrefixesCommandHandler( + mock(MaterializedBranchRepository.class), + mock(BranchRepository.class), + mock(CreateCommitCommandHandler.class) + ); + + Map oldPrefixes = Map.of( + "old1", "http://example.org/old1/", + "old2", "http://example.org/old2/" + ); + Map newPrefixes = Map.of( + "new", "http://example.org/new/" + ); + + // Act + RDFPatch patch = handler.buildPrefixPatch( + oldPrefixes, + newPrefixes, + UpdatePrefixesCommand.Operation.PUT + ); + + // Assert + String patchStr = patch.toString(); + assertThat(patchStr).contains("PD old1:"); + assertThat(patchStr).contains("PD old2:"); + assertThat(patchStr).contains("PA new: "); + } + + @Test + void buildPrefixPatch_shouldGeneratePdDirectives_forDeleteOperation() { + // Arrange + UpdatePrefixesCommandHandler handler = new UpdatePrefixesCommandHandler( + mock(MaterializedBranchRepository.class), + mock(BranchRepository.class), + mock(CreateCommitCommandHandler.class) + ); + + Map oldPrefixes = Map.of(); + Map prefixesToDelete = Map.of( + "temp", "", + "test", "" + ); + + // Act + RDFPatch patch = handler.buildPrefixPatch( + oldPrefixes, + prefixesToDelete, + UpdatePrefixesCommand.Operation.DELETE + ); + + // Assert + String patchStr = patch.toString(); + assertThat(patchStr).contains("PD temp:"); + assertThat(patchStr).contains("PD test:"); + assertThat(patchStr).doesNotContain("PA"); // No additions + } +} +``` + +--- + +### Step 6: Run Tests and Fix Issues (30 minutes) + +**6.1 Run static analysis:** +```bash +mvn -q compile checkstyle:check spotbugs:check pmd:check +``` + +**6.2 Run tests:** +```bash +mvn -q test -Dtest=PrefixManagementIT +mvn -q test -Dtest=UpdatePrefixesCommandHandlerTest +``` + +**6.3 Fix any issues:** +- Checkstyle violations +- SpotBugs warnings (defensive copying, etc.) +- Test failures + +--- + +### Step 7: Verify CQRS Compliance (15 minutes) + +After implementation, invoke the specialized agent: + +``` +@cqrs-compliance-checker + +Please verify UpdatePrefixesCommandHandler follows CQRS patterns: +- Command handler should delegate to CreateCommitCommandHandler +- No new events should be created +- No direct repository writes +``` + +--- + +## Success Criteria + +### Functional +- ✅ GET returns prefixes from materialized branch +- ✅ PUT creates commit with PD (old) + PA (new) directives +- ✅ PATCH creates commit with PA directives only +- ✅ DELETE creates commit with PD directives +- ✅ All operations return 201 Created with commit metadata +- ✅ Location header points to created commit +- ✅ ETag contains commit ID + +### Quality +- ✅ All tests pass (10+ integration tests) +- ✅ Zero Checkstyle violations +- ✅ Zero SpotBugs warnings +- ✅ Zero PMD violations +- ✅ Zero compiler warnings + +### CQRS Compliance +- ✅ No new events created (reuses CommitCreatedEvent) +- ✅ No direct repository writes +- ✅ Command handler delegates to CreateCommitCommandHandler +- ✅ Projector applies PA/PD directives automatically + +--- + +## Files to Create + +``` +src/main/java/org/chucc/vcserver/ + ├── dto/ + │ ├── PrefixResponse.java # NEW + │ ├── UpdatePrefixesRequest.java # NEW + │ └── CommitResponse.java # NEW (reusable) + ├── command/ + │ ├── UpdatePrefixesCommand.java # NEW + │ └── UpdatePrefixesCommandHandler.java # NEW + └── controller/ + └── PrefixManagementController.java # NEW + +src/test/java/org/chucc/vcserver/ + ├── integration/ + │ └── PrefixManagementIT.java # NEW + └── command/ + └── UpdatePrefixesCommandHandlerTest.java # NEW +``` + +--- + +## Common Issues and Solutions + +### Issue 1: RDFPatch Parsing Fails +**Symptom:** `RDFPatchOps.read()` throws exception + +**Solution:** Ensure proper format: +``` +TX . +PA foaf: . +PD temp: . +TC . +``` +(Note: space before period, no space after colon in PD) + +### Issue 2: Prefixes Not Updated +**Symptom:** GET returns old prefixes after PUT + +**Solution:** This is EXPECTED with projector disabled (test isolation pattern). The projector would apply PA/PD directives in production. + +### Issue 3: Missing Author Header Returns 500 +**Symptom:** Should return 400 Bad Request + +**Solution:** Add validation in controller: +```java +@RequestHeader("SPARQL-VC-Author") String author +``` +Spring will return 400 if header missing. + +--- + +## Next Steps + +After completing this session: + +1. ✅ Mark session as completed in [README.md](./README.md) +2. ✅ Commit changes with message: + ``` + feat(pmp): implement core prefix management operations + + - Add GET/PUT/PATCH/DELETE endpoints for prefix management + - Create UpdatePrefixesCommandHandler (reuses CreateCommitCommandHandler) + - Add PrefixManagementController + - Add integration tests (10+ tests) + - Add unit tests for command handler + + Prefix changes now create commits with RDFPatch PA/PD directives. + All operations version-controlled via existing CQRS infrastructure. + + 🤖 Generated with Claude Code + + Co-Authored-By: Claude + ``` +3. ✅ Proceed to [Session 2: Time-Travel Support](./session-2-time-travel-support.md) + +--- + +## References + +- [Prefix Management Protocol v1.0](../../protocol/Prefix_Management_Protocol.md) +- [CHUCC Implementation Guide](../../docs/api/prefix-management.md) +- [RDFPatch PA/PD Directives](https://afs.github.io/rdf-patch/#prefix-directives) +- [CreateCommitCommandHandler.java](../../src/main/java/org/chucc/vcserver/command/CreateCommitCommandHandler.java) + +--- + +**Ready to start!** Begin with Step 1 (Create DTOs) and work through sequentially. diff --git a/.tasks/pmp/session-2-time-travel-support.md b/.tasks/pmp/session-2-time-travel-support.md new file mode 100644 index 0000000..31cb7fe --- /dev/null +++ b/.tasks/pmp/session-2-time-travel-support.md @@ -0,0 +1,270 @@ +# Session 2: Time-Travel Prefix Queries + +**Status:** Not Started +**Estimated Time:** 2-3 hours +**Priority:** Medium +**Dependencies:** Session 1 (Core Implementation) + +--- + +## Overview + +Add ability to query prefixes at specific commits (historical queries). Enables users to see what prefixes existed at any point in history. + +**Goal:** Support `GET /version/datasets/{name}/commits/{id}/prefixes` for time-travel queries. + +--- + +## Requirements + +### Endpoint to Implement + +```http +GET /version/datasets/{dataset}/commits/{commitId}/prefixes +Accept: application/json + +→ 200 OK +{ + "dataset": "mydata", + "commitId": "01JCDN2XYZ...", + "prefixes": { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "foaf": "http://xmlns.com/foaf/0.1/" + } +} +``` + +**Note:** No `branch` field (this is a commit query, not branch query). + +--- + +## Implementation Steps + +### Step 1: Add Endpoint to Controller (30 minutes) + +```java +/** + * Retrieves prefix mappings at a specific commit (time-travel). + * + * @param dataset the dataset name + * @param commitId the commit ID + * @return prefix response + * @throws CommitNotFoundException if commit doesn't exist + */ +@GetMapping("/commits/{commitId}/prefixes") +public ResponseEntity getPrefixesAtCommit( + @PathVariable String dataset, + @PathVariable String commitId) { + + // Rebuild dataset at specific commit + DatasetGraph dsg = materializedViewRebuildService + .rebuildAtCommit(dataset, commitId); + + Map prefixes = dsg.getDefaultGraph() + .getPrefixMapping() + .getNsPrefixMap(); + + PrefixResponse response = new PrefixResponse( + dataset, + null, // No branch (commit query) + commitId, + prefixes + ); + + return ResponseEntity + .ok() + .eTag(commitId) + .body(response); +} +``` + +**Key insight:** Reuses existing `MaterializedViewRebuildService` - no new code needed! + +--- + +### Step 2: Add Dependency Injection (5 minutes) + +```java +private final MaterializedViewRebuildService materializedViewRebuildService; + +public PrefixManagementController( + MaterializedBranchRepository materializedBranchRepository, + BranchRepository branchRepository, + UpdatePrefixesCommandHandler updatePrefixesCommandHandler, + MaterializedViewRebuildService materializedViewRebuildService) { // NEW + this.materializedBranchRepository = materializedBranchRepository; + this.branchRepository = branchRepository; + this.updatePrefixesCommandHandler = updatePrefixesCommandHandler; + this.materializedViewRebuildService = materializedViewRebuildService; // NEW +} +``` + +--- + +### Step 3: Write Integration Tests (60 minutes) + +**Add to `PrefixManagementIT.java`:** + +```java +@Test +void getPrefixesAtCommit_shouldReturnHistoricalPrefixes() { + // Arrange: Create commit with specific prefixes + UpdatePrefixesRequest request = new UpdatePrefixesRequest( + "Add historical prefixes", + Map.of("old", "http://example.org/old/") + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("SPARQL-VC-Author", "TestUser "); + HttpEntity httpEntity = new HttpEntity<>(request, headers); + + ResponseEntity createResponse = restTemplate.exchange( + "/version/datasets/default/branches/main/prefixes", + HttpMethod.PUT, + httpEntity, + CommitResponse.class + ); + + String commitId = createResponse.getBody().commitId(); + + // Act: Query prefixes at that commit + ResponseEntity response = restTemplate.exchange( + "/version/datasets/default/commits/" + commitId + "/prefixes", + HttpMethod.GET, + null, + PrefixResponse.class + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().commitId()).isEqualTo(commitId); + assertThat(response.getBody().branch()).isNull(); // No branch (commit query) + assertThat(response.getBody().prefixes()).containsKey("old"); +} + +@Test +void getPrefixesAtCommit_shouldReturn404_whenCommitNotFound() { + // Act + ResponseEntity response = restTemplate.exchange( + "/version/datasets/default/commits/nonexistent-commit-id/prefixes", + HttpMethod.GET, + null, + String.class + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); +} + +@Test +void getPrefixesAtCommit_shouldReturnDifferentPrefixes_forDifferentCommits() { + // Arrange: Create two commits with different prefixes + // Commit 1: Add "v1" prefix + UpdatePrefixesRequest request1 = new UpdatePrefixesRequest( + "Version 1 prefixes", + Map.of("v1", "http://example.org/v1/") + ); + String commitId1 = createPrefixCommit(request1); + + // Commit 2: Add "v2" prefix + UpdatePrefixesRequest request2 = new UpdatePrefixesRequest( + "Version 2 prefixes", + Map.of("v2", "http://example.org/v2/") + ); + String commitId2 = createPrefixCommit(request2); + + // Act: Query both commits + PrefixResponse response1 = getPrefixesAtCommit(commitId1); + PrefixResponse response2 = getPrefixesAtCommit(commitId2); + + // Assert + assertThat(response1.prefixes()).containsKey("v1"); + assertThat(response1.prefixes()).doesNotContainKey("v2"); + + assertThat(response2.prefixes()).containsKey("v1"); // Inherited + assertThat(response2.prefixes()).containsKey("v2"); // Added +} +``` + +Add 2-3 more tests: +- `getPrefixesAtCommit_shouldUseCacheForRecentCommits()` +- `getPrefixesAtCommit_shouldRebuildForOldCommits()` + +--- + +### Step 4: Performance Testing (30 minutes) + +**Verify caching behavior:** + +```java +@Test +void getPrefixesAtCommit_shouldBeFast_whenCommitCached() { + // Arrange: Create commit + String commitId = createPrefixCommit(...); + + // Prime cache by accessing commit + materializedViewRebuildService.rebuildAtCommit("default", commitId); + + // Act: Time subsequent access + long start = System.currentTimeMillis(); + getPrefixesAtCommit(commitId); + long duration = System.currentTimeMillis() - start; + + // Assert: Should be fast (<50ms for cached) + assertThat(duration).isLessThan(50); +} + +@Test +void getPrefixesAtCommit_shouldRebuildOnDemand_whenNotCached() { + // Arrange: Create old commit that might be evicted + String oldCommitId = createPrefixCommit(...); + + // Act: Query (may require rebuild) + ResponseEntity response = getPrefixesAtCommit(oldCommitId); + + // Assert: Should work (even if slower) + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + // Typically ~1s for rebuild with 100 commits +} +``` + +--- + +## Success Criteria + +### Functional +- ✅ GET `/commits/{id}/prefixes` returns historical prefixes +- ✅ Response includes `commitId`, no `branch` +- ✅ Works for any commit (cached or not) +- ✅ Returns 404 if commit doesn't exist + +### Performance +- ✅ Cached commits: <50ms +- ✅ Uncached commits: <2s typical (rebuild) +- ✅ No memory leaks (cache eviction works) + +### Quality +- ✅ All tests pass (5+ new tests) +- ✅ Zero quality violations + +--- + +## Files to Modify + +``` +src/main/java/org/chucc/vcserver/controller/ + └── PrefixManagementController.java # Add getPrefixesAtCommit() method + +src/test/java/org/chucc/vcserver/integration/ + └── PrefixManagementIT.java # Add 5+ tests +``` + +--- + +## Next Steps + +After completing: +1. ✅ Commit changes +2. ✅ Proceed to [Session 3: Suggested Prefixes](./session-3-suggested-prefixes.md) diff --git a/.tasks/pmp/session-3-suggested-prefixes.md b/.tasks/pmp/session-3-suggested-prefixes.md new file mode 100644 index 0000000..3cdda6e --- /dev/null +++ b/.tasks/pmp/session-3-suggested-prefixes.md @@ -0,0 +1,452 @@ +# Session 3: Suggested Prefixes (Namespace Discovery) + +**Status:** Not Started +**Estimated Time:** 2-3 hours +**Priority:** Low (Nice-to-Have) +**Dependencies:** Session 1 (Core Implementation) + +--- + +## Overview + +Analyze dataset to discover common namespaces and suggest conventional prefixes. Helps users after importing RDF/XML or discovering new ontologies. + +**Goal:** Support `GET /version/datasets/{name}/branches/{branch}/prefixes/suggested` + +--- + +## Requirements + +### Endpoint to Implement + +```http +GET /version/datasets/{dataset}/branches/{branch}/prefixes/suggested +Accept: application/json + +→ 200 OK +{ + "dataset": "mydata", + "branch": "main", + "suggestions": [ + { + "prefix": "foaf", + "iri": "http://xmlns.com/foaf/0.1/", + "frequency": 42, + "status": "already_defined" + }, + { + "prefix": "schema", + "iri": "http://schema.org/", + "frequency": 38, + "status": "suggested" + }, + { + "prefix": "dbo", + "iri": "http://dbpedia.org/ontology/", + "frequency": 15, + "status": "suggested" + } + ] +} +``` + +--- + +## Implementation Steps + +### Step 1: Create DTOs (20 minutes) + +```java +package org.chucc.vcserver.dto; + +/** + * Suggested prefix for a namespace. + */ +public record PrefixSuggestion( + String prefix, + String iri, + int frequency, // How many times namespace appears + Status status +) { + public enum Status { + SUGGESTED, // Not yet defined + ALREADY_DEFINED // Already in prefix map + } +} + +/** + * Response containing prefix suggestions. + */ +public record SuggestedPrefixesResponse( + String dataset, + String branch, + List suggestions +) { + public SuggestedPrefixesResponse { + suggestions = List.copyOf(suggestions); + } +} +``` + +--- + +### Step 2: Create Conventional Prefix Database (30 minutes) + +```java +package org.chucc.vcserver.service; + +import java.util.Map; + +/** + * Database of conventional prefixes for common namespaces. + * + *

Subset of prefix.cc database. + */ +public class ConventionalPrefixes { + + private static final Map CONVENTIONAL_PREFIXES = Map.ofEntries( + // Core RDF/OWL/RDFS + Map.entry("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf"), + Map.entry("http://www.w3.org/2000/01/rdf-schema#", "rdfs"), + Map.entry("http://www.w3.org/2002/07/owl#", "owl"), + Map.entry("http://www.w3.org/2001/XMLSchema#", "xsd"), + + // Popular ontologies + Map.entry("http://xmlns.com/foaf/0.1/", "foaf"), + Map.entry("http://purl.org/dc/terms/", "dct"), + Map.entry("http://purl.org/dc/elements/1.1/", "dc"), + Map.entry("http://schema.org/", "schema"), + Map.entry("http://www.w3.org/2004/02/skos/core#", "skos"), + + // Geospatial + Map.entry("http://www.opengis.net/ont/geosparql#", "geo"), + Map.entry("http://www.opengis.net/ont/sf#", "sf"), + Map.entry("http://www.w3.org/2003/01/geo/wgs84_pos#", "wgs84"), + + // DBpedia + Map.entry("http://dbpedia.org/ontology/", "dbo"), + Map.entry("http://dbpedia.org/resource/", "dbr"), + Map.entry("http://dbpedia.org/property/", "dbp"), + + // PROV + Map.entry("http://www.w3.org/ns/prov#", "prov"), + + // Time + Map.entry("http://www.w3.org/2006/time#", "time"), + + // Add more as needed... + ); + + /** + * Gets conventional prefix for a namespace. + * + * @param namespace the namespace IRI + * @return the conventional prefix, or null if unknown + */ + public static String getConventionalPrefix(String namespace) { + return CONVENTIONAL_PREFIXES.get(namespace); + } + + /** + * Checks if namespace has a conventional prefix. + * + * @param namespace the namespace IRI + * @return true if conventional prefix exists + */ + public static boolean hasConventionalPrefix(String namespace) { + return CONVENTIONAL_PREFIXES.containsKey(namespace); + } +} +``` + +--- + +### Step 3: Create PrefixSuggestionService (60 minutes) + +```java +package org.chucc.vcserver.service; + +import java.util.*; +import java.util.stream.Collectors; +import org.apache.jena.graph.Node; +import org.apache.jena.graph.Triple; +import org.apache.jena.sparql.core.DatasetGraph; +import org.chucc.vcserver.dto.PrefixSuggestion; +import org.chucc.vcserver.dto.PrefixSuggestion.Status; +import org.chucc.vcserver.repository.MaterializedBranchRepository; +import org.springframework.stereotype.Service; + +/** + * Service for suggesting prefix mappings based on dataset analysis. + */ +@Service +public class PrefixSuggestionService { + + private final MaterializedBranchRepository materializedBranchRepository; + + public PrefixSuggestionService(MaterializedBranchRepository materializedBranchRepository) { + this.materializedBranchRepository = materializedBranchRepository; + } + + /** + * Analyzes dataset and suggests conventional prefixes. + * + * @param dataset the dataset name + * @param branch the branch name + * @return list of prefix suggestions, sorted by frequency descending + */ + public List analyzeBranch(String dataset, String branch) { + DatasetGraph dsg = materializedBranchRepository.getMaterializedBranch(dataset, branch); + + // 1. Get current prefixes + Map currentPrefixes = dsg.getDefaultGraph() + .getPrefixMapping() + .getNsPrefixMap(); + + // Invert map for lookup (IRI → prefix) + Map iriToPrefixMap = currentPrefixes.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)); + + // 2. Scan dataset for namespace patterns + Map namespaceFrequency = scanForNamespaces(dsg); + + // 3. Match against conventional prefixes + List suggestions = new ArrayList<>(); + + for (Map.Entry entry : namespaceFrequency.entrySet()) { + String namespace = entry.getKey(); + int frequency = entry.getValue(); + + String conventionalPrefix = ConventionalPrefixes.getConventionalPrefix(namespace); + if (conventionalPrefix != null) { + Status status = iriToPrefixMap.containsKey(namespace) + ? Status.ALREADY_DEFINED + : Status.SUGGESTED; + + suggestions.add(new PrefixSuggestion( + conventionalPrefix, + namespace, + frequency, + status + )); + } + } + + // 4. Sort by frequency descending + suggestions.sort(Comparator.comparingInt(PrefixSuggestion::frequency).reversed()); + + return suggestions; + } + + /** + * Scans dataset for namespace patterns. + * + * @param dsg the dataset graph + * @return map of namespace → frequency + */ + private Map scanForNamespaces(DatasetGraph dsg) { + Map namespaceFrequency = new HashMap<>(); + + // Scan all graphs + dsg.listGraphNodes().forEachRemaining(graphName -> { + dsg.getGraph(graphName).find().forEachRemaining(triple -> { + extractNamespace(triple.getSubject()).ifPresent(ns -> + namespaceFrequency.merge(ns, 1, Integer::sum)); + extractNamespace(triple.getPredicate()).ifPresent(ns -> + namespaceFrequency.merge(ns, 1, Integer::sum)); + extractNamespace(triple.getObject()).ifPresent(ns -> + namespaceFrequency.merge(ns, 1, Integer::sum)); + }); + }); + + // Scan default graph + dsg.getDefaultGraph().find().forEachRemaining(triple -> { + extractNamespace(triple.getSubject()).ifPresent(ns -> + namespaceFrequency.merge(ns, 1, Integer::sum)); + extractNamespace(triple.getPredicate()).ifPresent(ns -> + namespaceFrequency.merge(ns, 1, Integer::sum)); + extractNamespace(triple.getObject()).ifPresent(ns -> + namespaceFrequency.merge(ns, 1, Integer::sum)); + }); + + return namespaceFrequency; + } + + /** + * Extracts namespace from a node (if it's a URI). + * + * @param node the RDF node + * @return optional namespace + */ + private Optional extractNamespace(Node node) { + if (!node.isURI()) { + return Optional.empty(); + } + + String uri = node.getURI(); + + // Extract namespace (everything up to last # or /) + int hashIndex = uri.lastIndexOf('#'); + int slashIndex = uri.lastIndexOf('/'); + int splitIndex = Math.max(hashIndex, slashIndex); + + if (splitIndex > 0) { + return Optional.of(uri.substring(0, splitIndex + 1)); + } + + return Optional.empty(); + } +} +``` + +--- + +### Step 4: Add Controller Endpoint (20 minutes) + +```java +/** + * Suggests prefix mappings based on dataset analysis. + * + * @param dataset the dataset name + * @param branch the branch name + * @return prefix suggestions + */ +@GetMapping("/branches/{branch}/prefixes/suggested") +public ResponseEntity suggestPrefixes( + @PathVariable String dataset, + @PathVariable String branch) { + + List suggestions = prefixSuggestionService + .analyzeBranch(dataset, branch); + + SuggestedPrefixesResponse response = new SuggestedPrefixesResponse( + dataset, + branch, + suggestions + ); + + return ResponseEntity.ok(response); +} +``` + +--- + +### Step 5: Write Tests (40 minutes) + +```java +@Test +void suggestPrefixes_shouldReturnSuggestions() { + // Arrange: Add triples with known namespaces + String rdfPatch = """ + TX . + A "Alice" . + A "Bob" . + TC . + """; + createCommitWithPatch(rdfPatch); + + // Act + ResponseEntity response = restTemplate.exchange( + "/version/datasets/default/branches/main/prefixes/suggested", + HttpMethod.GET, + null, + SuggestedPrefixesResponse.class + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().suggestions()) + .anyMatch(s -> s.prefix().equals("foaf") + && s.iri().equals("http://xmlns.com/foaf/0.1/") + && s.status() == Status.SUGGESTED); +} + +@Test +void suggestPrefixes_shouldMarkAlreadyDefined() { + // Arrange: Define foaf prefix + definePrefixes(Map.of("foaf", "http://xmlns.com/foaf/0.1/")); + + // Add triples using foaf namespace + createCommitWithTriples(...); + + // Act + SuggestedPrefixesResponse response = getSuggestions(); + + // Assert + assertThat(response.suggestions()) + .anyMatch(s -> s.prefix().equals("foaf") + && s.status() == Status.ALREADY_DEFINED); +} + +@Test +void suggestPrefixes_shouldSortByFrequency() { + // Arrange: Add triples with multiple namespaces + // 10x foaf, 5x schema, 2x dbo + createCommitWithMultipleNamespaces(); + + // Act + List suggestions = getSuggestions().suggestions(); + + // Assert + assertThat(suggestions.get(0).prefix()).isEqualTo("foaf"); // Most frequent + assertThat(suggestions.get(1).prefix()).isEqualTo("schema"); + assertThat(suggestions.get(2).prefix()).isEqualTo("dbo"); +} +``` + +--- + +## Success Criteria + +### Functional +- ✅ Analyzes dataset for namespace patterns +- ✅ Suggests conventional prefixes (prefix.cc subset) +- ✅ Marks already-defined prefixes +- ✅ Sorts by frequency descending +- ✅ Returns empty list if no suggestions + +### Performance +- ✅ Completes in <500ms for typical datasets +- ✅ No memory issues for large datasets + +### Quality +- ✅ All tests pass (8+ new tests) +- ✅ Zero quality violations + +--- + +## Files to Create + +``` +src/main/java/org/chucc/vcserver/ + ├── dto/ + │ ├── PrefixSuggestion.java # NEW + │ └── SuggestedPrefixesResponse.java # NEW + └── service/ + ├── ConventionalPrefixes.java # NEW + └── PrefixSuggestionService.java # NEW + +src/main/java/org/chucc/vcserver/controller/ + └── PrefixManagementController.java # MODIFY (add endpoint) + +src/test/java/org/chucc/vcserver/integration/ + └── PrefixManagementIT.java # MODIFY (add 8+ tests) +``` + +--- + +## Optional Enhancements + +1. **Threshold filtering**: Only suggest namespaces with frequency ≥ N +2. **Custom prefixes**: Allow users to define their own conventional prefixes +3. **Prefix.cc API**: Fetch live data from prefix.cc instead of static map +4. **Conflict detection**: Warn if suggested prefix already used for different namespace + +--- + +## Next Steps + +After completing: +1. ✅ Commit changes +2. ✅ Proceed to [Session 4: OpenAPI and Tests](./session-4-openapi-and-tests.md) diff --git a/.tasks/pmp/session-4-openapi-and-tests.md b/.tasks/pmp/session-4-openapi-and-tests.md new file mode 100644 index 0000000..3cae06f --- /dev/null +++ b/.tasks/pmp/session-4-openapi-and-tests.md @@ -0,0 +1,207 @@ +# Session 4: OpenAPI Documentation and Comprehensive Testing + +**Status:** Not Started +**Estimated Time:** 2-3 hours +**Priority:** Medium +**Dependencies:** Sessions 1-3 + +--- + +## Overview + +Complete OpenAPI documentation, add comprehensive error handling tests, and validate cross-protocol integration. + +**Goal:** Production-ready documentation and test coverage. + +--- + +## Tasks + +### 1. OpenAPI Documentation (60 minutes) + +Add to existing OpenAPI spec: + +```yaml +/version/datasets/{dataset}/branches/{branch}/prefixes: + get: + summary: Retrieve prefix mappings for a branch + operationId: getPrefixes + tags: [Prefix Management] + parameters: + - $ref: '#/components/parameters/DatasetParam' + - $ref: '#/components/parameters/BranchParam' + responses: + '200': + description: Prefix map retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/PrefixResponse' + '404': + $ref: '#/components/responses/NotFound' + + put: + summary: Replace entire prefix map (creates commit) + operationId: replacePrefixes + tags: [Prefix Management] + parameters: + - $ref: '#/components/parameters/DatasetParam' + - $ref: '#/components/parameters/BranchParam' + - $ref: '#/components/parameters/AuthorHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePrefixesRequest' + responses: + '201': + description: Commit created + headers: + Location: + schema: + type: string + ETag: + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/CommitResponse' + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/ProtectedBranch' + '404': + $ref: '#/components/responses/NotFound' + + # Add PATCH, DELETE similarly... + +components: + schemas: + PrefixResponse: + type: object + required: [dataset, commitId, prefixes] + properties: + dataset: + type: string + branch: + type: string + nullable: true + commitId: + type: string + prefixes: + type: object + additionalProperties: + type: string + + UpdatePrefixesRequest: + type: object + required: [prefixes] + properties: + message: + type: string + prefixes: + type: object + additionalProperties: + type: string +``` + +--- + +### 2. Error Handling Tests (60 minutes) + +```java +@Test +void putPrefixes_shouldReturn403_whenBranchProtected() { + // Arrange: Protect main branch + protectBranch("default", "main"); + + // Act + ResponseEntity response = putPrefixes(...); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); +} + +@Test +void putPrefixes_shouldReturn400_whenInvalidPrefixName() { + UpdatePrefixesRequest request = new UpdatePrefixesRequest( + null, + Map.of("1invalid", "http://example.org/") // Starts with number + ); + + // Act & Assert + assertThat(putPrefixes(request).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); +} + +@Test +void putPrefixes_shouldReturn400_whenRelativeIRI() { + UpdatePrefixesRequest request = new UpdatePrefixesRequest( + null, + Map.of("ex", "../relative") // Not absolute + ); + + // Act & Assert + assertThat(putPrefixes(request).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); +} +``` + +--- + +### 3. Cross-Protocol Integration Tests (30 minutes) + +```java +@Test +void prefixesShouldPersistAfterGraphOperations() { + // Arrange: Define prefixes + definePrefixes(Map.of("foaf", "http://xmlns.com/foaf/0.1/")); + + // Act: Perform GSP operation + putGraph("http://example.org/g1", ""); + + // Assert: Prefixes still present + assertThat(getPrefixes().prefixes()).containsKey("foaf"); +} + +@Test +void prefixesShouldMergeAutomatically() { + // Arrange: Create branch with different prefixes + createBranch("dev"); + definePrefixesOnBranch("dev", Map.of("geo", "http://...")); + + // Act: Merge + mergeBranch("dev", "main"); + + // Assert: Both prefix sets present + PrefixResponse mainPrefixes = getPrefixesOnBranch("main"); + assertThat(mainPrefixes.prefixes()).containsKey("geo"); +} +``` + +--- + +## Files to Modify + +``` +docs/openapi/ + └── prefix-management.yaml # NEW or add to existing + +src/test/java/org/chucc/vcserver/integration/ + └── PrefixManagementIT.java # Add 10+ validation tests +``` + +--- + +## Success Criteria + +- ✅ Complete OpenAPI documentation +- ✅ All error cases covered (400, 403, 404) +- ✅ Cross-protocol tests pass +- ✅ Validation tests pass + +--- + +**Next:** [Session 5: Merge Conflict Handling](./session-5-merge-conflict-handling.md) (Optional) diff --git a/.tasks/pmp/session-5-merge-conflict-handling.md b/.tasks/pmp/session-5-merge-conflict-handling.md new file mode 100644 index 0000000..6f6a4c1 --- /dev/null +++ b/.tasks/pmp/session-5-merge-conflict-handling.md @@ -0,0 +1,211 @@ +# Session 5: Merge Conflict Handling (Optional Enhancement) + +**Status:** Deferred +**Estimated Time:** 3-4 hours +**Priority:** Low (Future Enhancement) +**Dependencies:** Sessions 1-4, Merge API + +--- + +## Overview + +Enhance merge conflict detection to specifically identify and report prefix conflicts. Currently, prefix conflicts are handled as generic patch conflicts. + +**Goal:** Specialized conflict reporting for prefix mismatches. + +--- + +## Current Behavior + +When same prefix points to different IRIs in two branches: + +``` +Branch main: PA foaf: . +Branch dev: PA foaf: . + +Merge dev → main → Generic conflict +``` + +**Response:** +```json +{ + "type": "/problems/merge-conflict", + "conflicts": [ + { + "graph": "urn:x-arq:DefaultGraph", + "conflictingQuads": 1 + } + ] +} +``` + +--- + +## Enhanced Behavior (This Session) + +**Response with prefix-specific conflict:** +```json +{ + "type": "/problems/merge-conflict", + "conflicts": [ + { + "type": "prefix", + "prefix": "foaf", + "ours": "http://xmlns.com/foaf/0.1/", + "theirs": "http://example.org/my-foaf#" + } + ] +} +``` + +--- + +## Implementation Tasks + +### 1. Detect Prefix Conflicts (90 minutes) + +Add to `MergeCommandHandler`: + +```java +private List detectPrefixConflicts( + DatasetGraph base, + DatasetGraph ours, + DatasetGraph theirs) { + + Map basePrefixes = extractPrefixes(base); + Map ourPrefixes = extractPrefixes(ours); + Map theirPrefixes = extractPrefixes(theirs); + + List conflicts = new ArrayList<>(); + + for (String prefix : theirPrefixes.keySet()) { + String theirIRI = theirPrefixes.get(prefix); + String ourIRI = ourPrefixes.get(prefix); + String baseIRI = basePrefixes.get(prefix); + + // Conflict: Both changed prefix to different values + if (ourIRI != null && !ourIRI.equals(theirIRI) && !ourIRI.equals(baseIRI)) { + conflicts.add(new PrefixConflict(prefix, ourIRI, theirIRI)); + } + } + + return conflicts; +} +``` + +--- + +### 2. Update MergeConflict DTO (20 minutes) + +```java +public record MergeConflict( + ConflictType type, + String graph, // For quad conflicts + int conflictingQuads, // For quad conflicts + String prefix, // For prefix conflicts + String ours, // For prefix conflicts + String theirs // For prefix conflicts +) { + public enum ConflictType { + QUAD, + PREFIX + } +} +``` + +--- + +### 3. Resolution Strategies (60 minutes) + +Support prefix-specific resolution: + +```json +{ + "sourceBranch": "dev", + "resolutions": { + "prefixes": { + "foaf": "http://xmlns.com/foaf/0.1/" // Resolve to this IRI + } + } +} +``` + +--- + +### 4. Tests (60 minutes) + +```java +@Test +void merge_shouldDetectPrefixConflict() { + // Arrange: Create conflicting prefixes + definePrefixesOnBranch("main", Map.of("foaf", "http://xmlns.com/foaf/0.1/")); + definePrefixesOnBranch("dev", Map.of("foaf", "http://example.org/my-foaf#")); + + // Act + ResponseEntity response = mergeBranch("dev", "main"); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(response.getBody()).contains("\"type\":\"prefix\""); + assertThat(response.getBody()).contains("\"prefix\":\"foaf\""); +} + +@Test +void merge_shouldResolvePrefixConflict_withOursStrategy() { + // Arrange: Conflict + createPrefixConflict(); + + // Act: Merge with resolution + MergeRequest request = new MergeRequest( + "dev", + "ours", // Keep our prefix + null + ); + mergeBranch(request); + + // Assert: Our prefix kept + assertThat(getPrefixes().prefixes().get("foaf")) + .isEqualTo("http://xmlns.com/foaf/0.1/"); +} +``` + +--- + +## Files to Modify + +``` +src/main/java/org/chucc/vcserver/ + ├── command/ + │ └── MergeCommandHandler.java # Add prefix conflict detection + ├── dto/ + │ └── MergeConflict.java # Add PREFIX type + └── util/ + └── MergeUtil.java # Add prefix merge logic + +src/test/java/org/chucc/vcserver/integration/ + └── MergeOperationsIT.java # Add prefix conflict tests +``` + +--- + +## Success Criteria + +- ✅ Prefix conflicts detected and reported separately +- ✅ Resolution strategies work for prefixes +- ✅ Manual resolution supported +- ✅ Tests pass (6+ new tests) + +--- + +## Why Deferred? + +1. **Generic conflict handling works:** Current merge already detects conflicts (as quad-level) +2. **Low priority:** Prefix conflicts are rare in practice +3. **Complex implementation:** Requires changes to merge algorithm +4. **Better DX:** Nice-to-have for better error messages, not critical + +**Recommendation:** Implement only if users report confusion with generic conflict messages. + +--- + +**Status:** Optional enhancement - defer until after core PMP features deployed and user feedback collected. diff --git a/docs/api/prefix-management.md b/docs/api/prefix-management.md new file mode 100644 index 0000000..8e12330 --- /dev/null +++ b/docs/api/prefix-management.md @@ -0,0 +1,1068 @@ +# Prefix Management in CHUCC Server + +**Implementation Guide for [Prefix Management Protocol (PMP) v1.0](../../protocol/Prefix_Management_Protocol.md)** + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [URL Structure](#url-structure) +3. [Version Control Integration](#version-control-integration) +4. [Operations](#operations) +5. [Time-Travel Queries](#time-travel-queries) +6. [Merge Behavior](#merge-behavior) +7. [RDFPatch Integration](#rdfpatch-integration) +8. [IDE Integration](#ide-integration) +9. [Implementation Architecture](#implementation-architecture) +10. [Examples](#examples) + +--- + +## Overview + +CHUCC Server implements the [Prefix Management Protocol (PMP)](../../protocol/Prefix_Management_Protocol.md) with **version control integration**. Prefix changes are treated as first-class commits, enabling: + +✅ **Version history** - Track who changed prefixes and when +✅ **Time-travel** - View prefixes at any commit +✅ **Merge support** - Prefix changes merge automatically with branches +✅ **Audit trail** - Full history via event sourcing + +**Key Principle:** Prefix modifications create commits (not in-place updates). This follows CHUCC's CQRS + Event Sourcing architecture. + +--- + +## URL Structure + +### Base Protocol Compatibility + +CHUCC provides **both** generic and version-explicit endpoints: + +``` +# Generic endpoint (base protocol) +/datasets/{dataset}/prefixes + +# Version-explicit endpoints (CHUCC-specific) +/version/datasets/{dataset}/branches/{branch}/prefixes +/version/datasets/{dataset}/commits/{commitId}/prefixes +``` + +### Generic Endpoint Behavior + +The generic endpoint operates on the **HEAD of the default branch** (`main`): + +```http +GET /datasets/mydata/prefixes +↓ (internally resolves to) +GET /version/datasets/mydata/branches/main/prefixes +``` + +**Use case:** Generic SPARQL clients that don't need version control awareness. + +### Version-Explicit Endpoints (Recommended) + +For full version control capabilities, use explicit endpoints: + +```http +# Read prefixes from specific branch +GET /version/datasets/{dataset}/branches/{branch}/prefixes + +# Modify prefixes on specific branch (creates commit) +PUT /version/datasets/{dataset}/branches/{branch}/prefixes +PATCH /version/datasets/{dataset}/branches/{branch}/prefixes +DELETE /version/datasets/{dataset}/branches/{branch}/prefixes?prefix=... + +# Time-travel: Read prefixes at specific commit +GET /version/datasets/{dataset}/commits/{commitId}/prefixes +``` + +--- + +## Version Control Integration + +### Prefix Changes Create Commits + +**Unlike non-versioned servers** (which modify prefixes in-place), CHUCC treats prefix changes as **commit-creating operations**. + +**Example:** +```http +PATCH /version/datasets/mydata/branches/main/prefixes +Content-Type: application/json +SPARQL-VC-Author: Alice + +{ + "message": "Add geospatial prefixes", + "prefixes": { + "geo": "http://www.opengis.net/ont/geosparql#", + "sf": "http://www.opengis.net/ont/sf#" + } +} +``` + +**Response:** +```http +201 Created +Location: /version/datasets/mydata/commits/01JCDN4XYZ... +ETag: "01JCDN4XYZ..." +Content-Type: application/json + +{ + "dataset": "mydata", + "branch": "main", + "commitId": "01JCDN4XYZ...", + "message": "Add geospatial prefixes", + "author": "Alice ", + "timestamp": "2025-11-06T10:30:00Z" +} +``` + +**What happened internally:** +1. Current prefixes retrieved from materialized branch +2. RDFPatch generated with `PA` directives +3. `CommitCreatedEvent` published to Kafka +4. ReadModelProjector updates branch HEAD +5. Response returned immediately (eventual consistency) + +### Required Headers + +All write operations **MUST** include: + +```http +SPARQL-VC-Author: Alice +``` + +This is the commit author (same as other CHUCC version control operations). + +### Branch Protection + +Prefix modifications respect branch protection rules: + +```http +PUT /version/datasets/mydata/branches/main/prefixes +→ 403 Forbidden (if main is protected) + +{ + "type": "/problems/protected-branch", + "title": "Protected Branch", + "detail": "Branch 'main' is protected. Create a feature branch instead." +} +``` + +**Workaround:** Create feature branch, modify prefixes, merge via pull request. + +--- + +## Operations + +### GET - Retrieve Prefix Map + +**Read current branch:** +```http +GET /version/datasets/mydata/branches/main/prefixes +Accept: application/json +``` + +**Response:** +```json +{ + "dataset": "mydata", + "branch": "main", + "commitId": "01JCDN3KXQ...", + "prefixes": { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "foaf": "http://xmlns.com/foaf/0.1/", + "dct": "http://purl.org/dc/terms/" + } +} +``` + +**Implementation:** +```java +// Read from materialized branch (LRU cache) +DatasetGraph dsg = materializedBranchRepository + .getMaterializedBranch("mydata", "main"); +PrefixMapping pm = dsg.getDefaultGraph().getPrefixMapping(); +Map prefixes = pm.getNsPrefixMap(); +``` + +--- + +### PUT - Replace Entire Prefix Map + +**Replace all prefixes:** +```http +PUT /version/datasets/mydata/branches/main/prefixes +Content-Type: application/json +SPARQL-VC-Author: Alice + +{ + "message": "Simplify prefix map", + "prefixes": { + "ex": "http://example.org/", + "schema": "http://schema.org/" + } +} +``` + +**Generated RDFPatch:** +``` +TX . +# Delete old prefixes +PD rdf: . +PD rdfs: . +PD foaf: . +PD dct: . +# Add new prefixes +PA ex: . +PA schema: . +TC . +``` + +**Response:** +```http +201 Created +Location: /version/datasets/mydata/commits/01JCDN5ABC... +ETag: "01JCDN5ABC..." + +{ + "commitId": "01JCDN5ABC...", + "message": "Simplify prefix map" +} +``` + +--- + +### PATCH - Add/Update Selected Prefixes + +**Merge-update (non-destructive):** +```http +PATCH /version/datasets/mydata/branches/main/prefixes +Content-Type: application/json +SPARQL-VC-Author: Bob + +{ + "message": "Add Dublin Core terms", + "prefixes": { + "dct": "http://purl.org/dc/terms/", + "dcmi": "http://purl.org/dc/dcmitype/" + } +} +``` + +**Generated RDFPatch:** +``` +TX . +PA dct: . +PA dcmi: . +# Existing prefixes (ex, schema) unchanged +TC . +``` + +**Use case:** Add new prefixes without affecting existing ones. + +--- + +### DELETE - Remove Prefixes + +**Remove specific prefixes:** +```http +DELETE /version/datasets/mydata/branches/main/prefixes?prefix=temp&prefix=test +SPARQL-VC-Author: Alice +``` + +**Query parameters:** +- `prefix=temp` - Remove prefix `temp` +- `prefix=test` - Remove prefix `test` +- `message=Cleanup+temporary+prefixes` - Optional commit message (URL-encoded) + +**Generated RDFPatch:** +``` +TX . +PD temp: . +PD test: . +TC . +``` + +**Response:** +```http +201 Created +Location: /version/datasets/mydata/commits/01JCDN6DEF... + +{ + "commitId": "01JCDN6DEF...", + "message": "Cleanup temporary prefixes" +} +``` + +--- + +## Time-Travel Queries + +### View Prefixes at Specific Commit + +```http +GET /version/datasets/mydata/commits/01JCDN2XYZ.../prefixes +``` + +**Response:** +```json +{ + "dataset": "mydata", + "commitId": "01JCDN2XYZ...", + "prefixes": { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "foaf": "http://xmlns.com/foaf/0.1/" + } +} +``` + +**Implementation:** +```java +// Rebuild dataset at specific commit +DatasetGraph dsg = materializedViewRebuildService + .rebuildAtCommit("mydata", "01JCDN2XYZ..."); +PrefixMapping pm = dsg.getDefaultGraph().getPrefixMapping(); +``` + +**Performance:** +- If commit is cached → instant (materialized view already built) +- If not cached → ~1s rebuild time (typical for 100 commits) + +### Compare Prefixes Between Commits + +```bash +# Use standard diff endpoint with ?prefixes-only flag +GET /version/datasets/mydata/commits/01JCDN2.../diff/01JCDN5...?prefixes-only=true +``` + +**Response:** +```json +{ + "added": { + "geo": "http://www.opengis.net/ont/geosparql#" + }, + "removed": { + "temp": "http://example.org/temp/" + }, + "modified": { + "foaf": { + "old": "http://xmlns.com/foaf/0.1/", + "new": "http://example.org/my-foaf#" + } + } +} +``` + +**Note:** This endpoint is planned but not yet implemented. See `.tasks/pmp/05-diff-support.md`. + +--- + +## Merge Behavior + +### Automatic Merge + +Prefix changes **merge automatically** via RDFPatch replay: + +**Scenario:** +``` +main: PA rdf: <...> . PA foaf: <...> . +dev: PA geo: <...> . PA dct: <...> . + +Merge dev → main +Result: PA rdf: <...> . PA foaf: <...> . PA geo: <...> . PA dct: <...> . +``` + +**No conflicts** - Prefixes are additive. + +### Prefix Conflicts + +**Conflict occurs when same prefix has different IRIs:** + +``` +main: PA foaf: . +dev: PA foaf: . + +Merge dev → main → CONFLICT! +``` + +**Merge response:** +```http +POST /version/datasets/mydata/branches/main/merge +{ + "sourceBranch": "dev", + "message": "Merge dev into main" +} + +→ 409 Conflict + +{ + "type": "/problems/merge-conflict", + "title": "Merge Conflict", + "conflicts": [ + { + "type": "prefix", + "prefix": "foaf", + "ours": "http://xmlns.com/foaf/0.1/", + "theirs": "http://example.org/my-foaf#" + } + ] +} +``` + +### Conflict Resolution + +**Option 1: Choose resolution strategy** +```http +POST /version/datasets/mydata/branches/main/merge +{ + "sourceBranch": "dev", + "resolutionStrategy": "ours" +} + +→ 201 Created (keeps main's foaf prefix) +``` + +**Option 2: Manual resolution** +```http +POST /version/datasets/mydata/branches/main/merge +{ + "sourceBranch": "dev", + "resolutions": { + "prefixes": { + "foaf": "http://xmlns.com/foaf/0.1/" + } + } +} +``` + +**Option 3: Resolve via separate commit** +```http +# After failed merge, fix prefixes manually +PATCH /version/datasets/mydata/branches/main/prefixes +{ + "message": "Resolve foaf prefix conflict", + "prefixes": { + "foaf": "http://xmlns.com/foaf/0.1/" + } +} + +# Then retry merge +POST /version/datasets/mydata/branches/main/merge +{ + "sourceBranch": "dev" +} +``` + +--- + +## RDFPatch Integration + +### PA/PD Directives + +CHUCC uses [RDFPatch](https://afs.github.io/rdf-patch/) to represent all changes, including prefix modifications: + +**Directives:** +- `PA prefix: ` - Add prefix (or update if exists) +- `PD prefix:` - Delete prefix + +### Example Commit + +**Commit metadata:** +```json +{ + "commitId": "01JCDN4XYZ...", + "message": "Add FOAF prefix", + "author": "Alice ", + "timestamp": "2025-11-06T10:30:00Z", + "patch": "TX .\nPA foaf: .\nTC ." +} +``` + +**RDFPatch contents:** +``` +TX . +PA foaf: . +TC . +``` + +### Mixed Operations + +Prefixes can be modified **together with RDF data** in a single commit: + +``` +TX . +# Add prefix +PA ex: . +# Add triples using that prefix +A "Alice" . +TC . +``` + +**Use case:** Import RDF/XML with namespace declarations → single commit contains both prefixes and data. + +--- + +## IDE Integration + +### Use Case: SPARQL Query Editor + +**Workflow:** +1. User opens SPARQL editor +2. Editor fetches prefixes from CHUCC +3. Editor inserts `PREFIX` declarations into query template + +**JavaScript example:** +```javascript +async function loadPrefixesIntoEditor(dataset, branch) { + const response = await fetch( + `http://chucc-server/version/datasets/${dataset}/branches/${branch}/prefixes`, + { headers: { 'Accept': 'application/json' } } + ); + + const data = await response.json(); + + // Generate PREFIX block + const prefixBlock = Object.entries(data.prefixes) + .map(([prefix, iri]) => `PREFIX ${prefix}: <${iri}>`) + .join('\n'); + + // Insert into editor + editor.setValue(`${prefixBlock}\n\nSELECT * WHERE {\n ?s ?p ?o .\n}\nLIMIT 10`); +} +``` + +### Use Case: RDF/XML Import with Prefix Suggestion + +**Workflow:** +1. User uploads RDF/XML file +2. CHUCC extracts namespace declarations +3. IDE suggests adding prefixes via PATCH + +**Example:** +```xml + + + + +``` + +**Suggested action:** +```http +PATCH /version/datasets/mydata/branches/main/prefixes +{ + "message": "Add prefixes from imported RDF/XML", + "prefixes": { + "foaf": "http://xmlns.com/foaf/0.1/", + "schema": "http://schema.org/" + } +} +``` + +**VSCode Extension Example:** +```typescript +// Show quickfix: "Add discovered prefixes to dataset?" +const action = await vscode.window.showInformationMessage( + 'Found 2 new namespace prefixes in RDF/XML', + 'Add to Dataset', + 'Ignore' +); + +if (action === 'Add to Dataset') { + await fetch(`${chucc}/version/datasets/${dataset}/branches/${branch}/prefixes`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'SPARQL-VC-Author': getUserEmail() + }, + body: JSON.stringify({ + message: 'Add prefixes from imported RDF/XML', + prefixes: discoveredPrefixes + }) + }); +} +``` + +--- + +## Implementation Architecture + +### Command Handler + +**`UpdatePrefixesCommandHandler`** (to be implemented): + +```java +@Component +public class UpdatePrefixesCommandHandler { + + private final MaterializedBranchRepository materializedBranchRepository; + private final CreateCommitCommandHandler createCommitCommandHandler; + + public CommitMetadata handle(UpdatePrefixesCommand cmd) { + // 1. Get current prefixes from materialized branch + DatasetGraph currentDsg = materializedBranchRepository + .getMaterializedBranch(cmd.dataset(), cmd.branch()); + Map oldPrefixes = currentDsg.getDefaultGraph() + .getPrefixMapping().getNsPrefixMap(); + + // 2. Generate RDFPatch with PA/PD directives + RDFPatch patch = buildPrefixPatch( + oldPrefixes, + cmd.newPrefixes(), + cmd.operation() + ); + + // 3. Create commit via existing handler + CreateCommitCommand commitCmd = new CreateCommitCommand( + cmd.dataset(), + cmd.branch(), + cmd.message().orElse(generateDefaultMessage(cmd)), + cmd.author(), + patch + ); + + return createCommitCommandHandler.handle(commitCmd); + } + + private RDFPatch buildPrefixPatch( + Map oldPrefixes, + Map newPrefixes, + Operation operation) { + + RDFPatchBuilder builder = RDFPatchBuilder.create(); + builder.txnBegin(); + + switch (operation) { + case PUT -> { + // Remove all old prefixes + oldPrefixes.forEach((prefix, iri) -> builder.prefixDelete(prefix)); + // Add all new prefixes + newPrefixes.forEach((prefix, iri) -> builder.prefixAdd(prefix, iri)); + } + case PATCH -> { + // Add/update only specified prefixes + newPrefixes.forEach((prefix, iri) -> builder.prefixAdd(prefix, iri)); + } + case DELETE -> { + // Remove specified prefixes + newPrefixes.keySet().forEach(builder::prefixDelete); + } + } + + builder.txnCommit(); + return builder.build(); + } +} +``` + +**Key insight:** Reuses existing `CreateCommitCommandHandler` - no new event types needed! + +### REST Controller + +**`PrefixManagementController`** (to be implemented): + +```java +@RestController +@RequestMapping("/version/datasets/{dataset}") +public class PrefixManagementController { + + @GetMapping("/branches/{branch}/prefixes") + public ResponseEntity getCurrentPrefixes( + @PathVariable String dataset, + @PathVariable String branch) { + + DatasetGraph dsg = materializedBranchRepository + .getMaterializedBranch(dataset, branch); + + Branch branchObj = branchRepository + .findByDatasetAndName(dataset, branch) + .orElseThrow(() -> new BranchNotFoundException(dataset, branch)); + + Map prefixes = dsg.getDefaultGraph() + .getPrefixMapping() + .getNsPrefixMap(); + + return ResponseEntity.ok(new PrefixResponse( + dataset, + branch, + branchObj.headCommitId(), + prefixes + )); + } + + @GetMapping("/commits/{commitId}/prefixes") + public ResponseEntity getPrefixesAtCommit( + @PathVariable String dataset, + @PathVariable String commitId) { + + DatasetGraph dsg = materializedViewRebuildService + .rebuildAtCommit(dataset, commitId); + + Map prefixes = dsg.getDefaultGraph() + .getPrefixMapping() + .getNsPrefixMap(); + + return ResponseEntity.ok(new PrefixResponse( + dataset, + null, // No branch (time-travel query) + commitId, + prefixes + )); + } + + @PutMapping("/branches/{branch}/prefixes") + public ResponseEntity replacePrefixes( + @PathVariable String dataset, + @PathVariable String branch, + @RequestHeader("SPARQL-VC-Author") String author, + @RequestBody UpdatePrefixesRequest request) { + + UpdatePrefixesCommand cmd = new UpdatePrefixesCommand( + dataset, + branch, + author, + request.prefixes(), + Operation.PUT, + Optional.ofNullable(request.message()) + ); + + CommitMetadata commit = updatePrefixesCommandHandler.handle(cmd); + + URI location = URI.create( + "/version/datasets/" + dataset + "/commits/" + commit.id() + ); + + return ResponseEntity + .created(location) + .eTag(commit.id()) + .body(new CommitResponse(dataset, branch, commit)); + } + + @PatchMapping("/branches/{branch}/prefixes") + public ResponseEntity updatePrefixes( + @PathVariable String dataset, + @PathVariable String branch, + @RequestHeader("SPARQL-VC-Author") String author, + @RequestBody UpdatePrefixesRequest request) { + + UpdatePrefixesCommand cmd = new UpdatePrefixesCommand( + dataset, + branch, + author, + request.prefixes(), + Operation.PATCH, + Optional.ofNullable(request.message()) + ); + + CommitMetadata commit = updatePrefixesCommandHandler.handle(cmd); + + return ResponseEntity + .created(URI.create("/version/datasets/" + dataset + "/commits/" + commit.id())) + .eTag(commit.id()) + .body(new CommitResponse(dataset, branch, commit)); + } + + @DeleteMapping("/branches/{branch}/prefixes") + public ResponseEntity deletePrefixes( + @PathVariable String dataset, + @PathVariable String branch, + @RequestParam List prefix, + @RequestParam(required = false) String message, + @RequestHeader("SPARQL-VC-Author") String author) { + + // Convert prefix list to map (value doesn't matter for DELETE) + Map prefixesToDelete = prefix.stream() + .collect(Collectors.toMap(p -> p, p -> "")); + + UpdatePrefixesCommand cmd = new UpdatePrefixesCommand( + dataset, + branch, + author, + prefixesToDelete, + Operation.DELETE, + Optional.ofNullable(message) + ); + + CommitMetadata commit = updatePrefixesCommandHandler.handle(cmd); + + return ResponseEntity + .created(URI.create("/version/datasets/" + dataset + "/commits/" + commit.id())) + .eTag(commit.id()) + .body(new CommitResponse(dataset, branch, commit)); + } +} +``` + +### Event Flow (Reuses Existing Architecture) + +``` +HTTP Request (PUT /prefixes) + ↓ +PrefixManagementController + ↓ +UpdatePrefixesCommandHandler + ↓ (generates RDFPatch with PA/PD) +CreateCommitCommandHandler + ↓ +CommitCreatedEvent published to Kafka + ↓ +ReadModelProjector consumes event + ↓ +Updates materialized branch (applies PA/PD) + ↓ +Prefix map updated in memory +``` + +**No new events, projectors, or repositories needed!** + +--- + +## Examples + +### Example 1: Complete Workflow + +**Step 1: Create dataset and check default prefixes** +```http +POST /version/datasets/mydata +SPARQL-VC-Author: Alice + +→ 202 Accepted + +GET /version/datasets/mydata/branches/main/prefixes + +→ 200 OK +{ + "prefixes": {} +} +``` + +**Step 2: Add common prefixes** +```http +PATCH /version/datasets/mydata/branches/main/prefixes +SPARQL-VC-Author: Alice + +{ + "message": "Add common prefixes", + "prefixes": { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "foaf": "http://xmlns.com/foaf/0.1/" + } +} + +→ 201 Created +{ + "commitId": "01JCDN7GHI...", + "message": "Add common prefixes" +} +``` + +**Step 3: Import RDF data** +```http +PUT /version/datasets/mydata/branches/main/data?default +Content-Type: application/rdf+xml +SPARQL-VC-Author: Alice + + + + Alice + + + +→ 201 Created +``` + +**Step 4: Query with prefixes** +```sparql +PREFIX foaf: + +SELECT ?name WHERE { + ?person foaf:name ?name . +} +``` + +**Step 5: View commit history (includes prefix changes)** +```http +GET /version/datasets/mydata/branches/main/commits + +→ 200 OK +[ + { + "commitId": "01JCDN8JKL...", + "message": "Add RDF data", + "author": "Alice " + }, + { + "commitId": "01JCDN7GHI...", + "message": "Add common prefixes", + "author": "Alice " + }, + { + "commitId": "01JCDN6ABC...", + "message": "Initial commit", + "author": "System" + } +] +``` + +--- + +### Example 2: Branch Workflow + +**Create feature branch and add ontology-specific prefixes** +```http +POST /version/datasets/mydata/branches +{ + "branchName": "add-geospatial", + "sourceBranch": "main" +} + +→ 201 Created + +PATCH /version/datasets/mydata/branches/add-geospatial/prefixes +SPARQL-VC-Author: Bob + +{ + "message": "Add geospatial prefixes", + "prefixes": { + "geo": "http://www.opengis.net/ont/geosparql#", + "sf": "http://www.opengis.net/ont/sf#", + "geof": "http://www.opengis.net/def/function/geosparql/" + } +} + +→ 201 Created + +# Merge back to main +POST /version/datasets/mydata/branches/main/merge +{ + "sourceBranch": "add-geospatial", + "message": "Merge geospatial prefixes" +} + +→ 201 Created (prefixes automatically merged) +``` + +--- + +### Example 3: Conflict Resolution + +**Create conflicting prefix definitions** +```http +# On main: Define foaf +PATCH /version/datasets/mydata/branches/main/prefixes +{ + "prefixes": { "foaf": "http://xmlns.com/foaf/0.1/" } +} + +# On dev: Define foaf differently +POST /version/datasets/mydata/branches +{ "branchName": "dev", "sourceBranch": "main" } + +PATCH /version/datasets/mydata/branches/dev/prefixes +{ + "prefixes": { "foaf": "http://example.org/my-foaf#" } +} + +# Try to merge +POST /version/datasets/mydata/branches/main/merge +{ + "sourceBranch": "dev" +} + +→ 409 Conflict +{ + "conflicts": [ + { + "type": "prefix", + "prefix": "foaf", + "ours": "http://xmlns.com/foaf/0.1/", + "theirs": "http://example.org/my-foaf#" + } + ] +} + +# Resolve: Keep main's version +POST /version/datasets/mydata/branches/main/merge +{ + "sourceBranch": "dev", + "resolutionStrategy": "ours" +} + +→ 201 Created +``` + +--- + +## Performance Considerations + +### Caching + +Prefix maps are **cached as part of materialized branches**: +- LRU eviction (default: 100 branches) +- Rebuilt on-demand if evicted (~1s typical) +- No separate prefix cache needed + +### Optimization: Prefix-Only Queries + +For extremely large datasets, reading prefixes is **very fast** (no triple scanning): + +```java +// O(1) - Just read prefix mapping +PrefixMapping pm = dsg.getDefaultGraph().getPrefixMapping(); +Map prefixes = pm.getNsPrefixMap(); +``` + +**Performance:** <1ms (in-memory hash map lookup) + +--- + +## Security Considerations + +### Authorization + +Prefix modifications require same permissions as graph modifications: +- Read permissions: Can GET prefixes +- Write permissions: Can PUT/PATCH/DELETE prefixes + +### Audit Trail + +All prefix changes are **auditable**: +- Stored in Kafka (permanent log) +- Commit metadata includes author and timestamp +- Can query: "Who changed the foaf prefix and when?" + +--- + +## Future Enhancements + +Planned features (see `.tasks/pmp/` for details): + +1. **Prefix suggestions** - Analyze dataset and suggest conventional prefixes +2. **Bulk operations** - Import/export entire prefix catalogs +3. **Prefix diff** - Compare prefix maps between commits +4. **Prefix templates** - Reusable prefix sets across datasets +5. **Conflict prevention** - Warn before creating conflicting prefix + +--- + +## Related Documentation + +- [Prefix Management Protocol (PMP) v1.0](../../protocol/Prefix_Management_Protocol.md) - Generic protocol specification +- [CQRS + Event Sourcing Architecture](../architecture/cqrs-event-sourcing.md) - Why prefixes create commits +- [RDFPatch Specification](https://afs.github.io/rdf-patch/) - PA/PD directive details +- [Version Control API](./version-control.md) - Branch, commit, merge operations + +--- + +## Support + +For implementation questions, see `.tasks/pmp/README.md` or contact the development team. + +--- + +**End of CHUCC Prefix Management Implementation Guide** diff --git a/protocol/Prefix_Management_Protocol.md b/protocol/Prefix_Management_Protocol.md new file mode 100644 index 0000000..2656452 --- /dev/null +++ b/protocol/Prefix_Management_Protocol.md @@ -0,0 +1,479 @@ +# Prefix Management Protocol (PMP) for SPARQL Services + +**Version:** 1.0 +**Status:** Draft +**Date:** 2025-11-06 + +--- + +## 1. Introduction + +This document defines an HTTP-based protocol for managing prefix declarations associated with a SPARQL dataset. + +SPARQL treats `PREFIX` declarations as query-local syntax and does not define a means to retrieve or modify server-side prefix mappings. Many SPARQL servers (e.g., Apache Jena Fuseki, Virtuoso) maintain prefix maps that are used when serializing RDF data (e.g., Turtle, JSON-LD). This protocol exposes those mappings in a simple, REST-style way so that SPARQL clients, UI editors, and administrative tools can synchronize their prefix lists with the server. + +**Design Goals:** +- Simple REST semantics (GET, PUT, PATCH, DELETE) +- Implementation-agnostic (works for any SPARQL server) +- Integrates naturally with RDFPatch for transactional consistency +- Supports both versioned and non-versioned implementations + +**Scope:** This protocol manages **dataset-level prefix maps only**. Graph-level prefix maps are not supported due to RDFPatch constraints. + +--- + +## 2. Terminology + +- **Prefix map**: An ordered mapping from a short prefix name (e.g., `rdf`) to an absolute IRI (e.g., `http://www.w3.org/1999/02/22-rdf-syntax-ns#`) +- **Dataset-level prefix map**: The prefix map associated with a SPARQL dataset as a whole +- **Service endpoint**: Base URL where this protocol is available (deployment-specific) + +--- + +## 3. Service Endpoint + +The Prefix Management Protocol is exposed at: + +``` +{dataset-base}/prefixes +``` + +**Example:** +``` +https://example.org/sparql/myDataset/prefixes +``` + +The exact URL structure is deployment-specific. + +--- + +## 4. Representation Format + +### 4.1 Media Type + +Servers **MUST** support `application/json`. + +### 4.2 JSON Structure + +**Request body (PUT/PATCH):** +```json +{ + "prefixes": { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "foaf": "http://xmlns.com/foaf/0.1/" + } +} +``` + +**Optional: Include commit message (for version-controlled servers):** +```json +{ + "message": "Add FOAF ontology prefixes", + "prefixes": { + "foaf": "http://xmlns.com/foaf/0.1/" + } +} +``` + +**Response body (GET):** +```json +{ + "prefixes": { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#" + } +} +``` + +--- + +## 5. Operations + +### 5.1 GET - Retrieve Prefix Map + +**Request:** +```http +GET /prefixes HTTP/1.1 +Accept: application/json +``` + +**Response:** +- `200 OK` - Returns current prefix map as JSON +- `404 Not Found` - Dataset does not exist + +**Example:** +```http +GET /myDataset/prefixes + +200 OK +Content-Type: application/json + +{ + "prefixes": { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "foaf": "http://xmlns.com/foaf/0.1/" + } +} +``` + +--- + +### 5.2 PUT - Replace Entire Prefix Map + +**Semantics:** Replace the entire prefix map with the provided mappings. Any existing prefix not included in the request body is removed. + +**Request:** +```http +PUT /prefixes HTTP/1.1 +Content-Type: application/json + +{ + "prefixes": { + "ex": "http://example.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#" + } +} +``` + +**Response:** +- `204 No Content` - Prefix map updated in-place (non-versioned server) +- `201 Created` - Commit created (version-controlled server) +- `400 Bad Request` - Invalid prefix name or IRI +- `404 Not Found` - Dataset does not exist + +**Version-controlled server response:** +```http +201 Created +Location: /version/datasets/myDataset/commits/01JCDN... +ETag: "01JCDN..." +Content-Type: application/json + +{ + "commitId": "01JCDN...", + "message": "Replace prefix map" +} +``` + +--- + +### 5.3 PATCH - Add or Update Selected Prefixes + +**Semantics:** Add new prefixes or update existing ones. Prefixes not mentioned in the request remain unchanged. + +**Request:** +```http +PATCH /prefixes HTTP/1.1 +Content-Type: application/json + +{ + "prefixes": { + "geo": "http://www.opengis.net/ont/geosparql#", + "dct": "http://purl.org/dc/terms/" + } +} +``` + +**Response:** +- `204 No Content` - Prefixes updated in-place (non-versioned server) +- `201 Created` - Commit created (version-controlled server) +- `400 Bad Request` - Invalid prefix name or IRI +- `404 Not Found` - Dataset does not exist + +--- + +### 5.4 DELETE - Remove Prefixes + +**Semantics:** Remove one or more prefixes from the prefix map. + +**Request:** +```http +DELETE /prefixes?prefix=ex&prefix=temp HTTP/1.1 +``` + +**Query parameters:** +- `prefix` - Name of prefix to remove (can be repeated for multiple prefixes) + +**Response:** +- `204 No Content` - Prefixes removed in-place (non-versioned server) +- `201 Created` - Commit created (version-controlled server) +- `404 Not Found` - Dataset does not exist + +**Note:** If a specified prefix does not exist, servers SHOULD ignore it silently (idempotent operation). + +--- + +## 6. Prefix Name and IRI Constraints + +### 6.1 Prefix Name Validation + +Prefix names **MUST** conform to SPARQL `PNAME_NS` rules (the part before the colon in `PREFIX rdf:`). + +**Valid examples:** `rdf`, `foaf`, `ex`, `my-ontology` +**Invalid examples:** `1foo`, `rdf:`, `http://example.org/` + +### 6.2 IRI Validation + +IRIs **MUST** be absolute IRIs (not relative). + +**Valid:** `http://example.org/`, `https://xmlns.com/foaf/0.1/` +**Invalid:** `../relative`, `example.org` (missing scheme) + +### 6.3 Error Response + +If validation fails, return: + +```http +400 Bad Request +Content-Type: application/json + +{ + "error": "InvalidPrefixName", + "message": "Prefix name '1foo' does not match SPARQL PNAME_NS rules" +} +``` + +--- + +## 7. RDFPatch Integration + +### 7.1 Background + +[RDFPatch](https://afs.github.io/rdf-patch/) is a standardized format for representing changes to RDF datasets. It includes directives for prefix management: +- `PA prefix: ` - Add prefix +- `PD prefix:` - Delete prefix + +### 7.2 Implementation Guidance + +Servers that use RDFPatch **SHOULD** translate prefix operations to PA/PD directives within transactions. + +**Example: PUT operation** + +Request: +```json +{ + "prefixes": { + "foaf": "http://xmlns.com/foaf/0.1/" + } +} +``` + +Translates to RDFPatch: +``` +TX . +PD rdf: . +PD rdfs: . +PA foaf: . +TC . +``` + +**Benefits:** +- Transactional consistency (all-or-nothing) +- Atomic with RDF data changes +- Automatic versioning (if server supports version control) +- Merge/replay capabilities + +--- + +## 8. Version-Controlled Implementations + +### 8.1 Behavior + +Servers with version control (branches, commits, etc.) **SHOULD**: +- Treat prefix modifications as commit-creating operations +- Return `201 Created` (instead of `204 No Content`) +- Include `Location` header pointing to created commit +- Include commit metadata in response body + +### 8.2 Response Format + +```http +201 Created +Location: /version/datasets/myDataset/commits/01JCDN... +ETag: "01JCDN..." +Content-Type: application/json + +{ + "commitId": "01JCDN...", + "message": "Add geospatial prefixes", + "author": "Alice ", + "timestamp": "2025-11-06T10:30:00Z" +} +``` + +### 8.3 Commit Messages + +Version-controlled servers **MAY** accept an optional `message` field: + +```json +{ + "message": "Add FOAF ontology prefixes", + "prefixes": { + "foaf": "http://xmlns.com/foaf/0.1/" + } +} +``` + +If not provided, servers should generate a default message (e.g., "Update prefixes"). + +### 8.4 Time-Travel and History + +Version-controlled servers **MAY** expose additional endpoints for: +- Retrieving prefixes at specific commits +- Viewing prefix change history +- Comparing prefix maps between branches + +These capabilities are **implementation-specific** and not defined in this protocol. + +--- + +## 9. Error Conditions + +| Status Code | Meaning | +|-------------|---------| +| `200 OK` | GET successful | +| `201 Created` | Modification created commit (versioned) | +| `204 No Content` | Modification applied in-place (non-versioned) | +| `400 Bad Request` | Invalid prefix name, IRI, or malformed JSON | +| `404 Not Found` | Dataset or graph does not exist | +| `415 Unsupported Media Type` | Client sent non-JSON content | +| `500 Internal Server Error` | Server-side storage failure | + +--- + +## 10. Security Considerations + +### 10.1 Authentication + +Servers **SHOULD** require authentication for write operations (PUT, PATCH, DELETE). + +### 10.2 Authorization + +Servers **SHOULD** enforce authorization policies: +- Who can read prefix maps? +- Who can modify prefix maps? +- Are certain prefixes protected from deletion? + +### 10.3 Injection Attacks + +Servers **MUST** validate: +- Prefix names conform to PNAME_NS rules +- IRIs are well-formed absolute IRIs +- JSON structure is valid + +Reject malicious input before processing. + +--- + +## 11. Interaction with SPARQL and GSP + +### 11.1 Query Behavior + +This protocol **does NOT change** how SPARQL queries are parsed or executed. A query without `PREFIX` declarations still requires full IRIs. + +**Server behavior is implementation-specific:** +- Some servers may inject prefixes into queries automatically +- Some servers only use prefixes for serialization +- Clients should not assume automatic prefix injection + +### 11.2 Serialization + +Servers **MAY** use the prefix map when serializing RDF data (e.g., Turtle responses from Graph Store Protocol). This improves readability but is not required. + +### 11.3 Coexistence + +This protocol can coexist with SPARQL Protocol and Graph Store Protocol on the same dataset: +- SPARQL Protocol: `/sparql` +- Graph Store Protocol: `/data` +- Prefix Management: `/prefixes` + +--- + +## 12. Conformance Levels + +### Level 1: Read-Only (Minimal) + +- **MUST** support `GET /prefixes` +- **MUST** return JSON with `prefixes` object +- **MUST** validate prefix names and IRIs + +### Level 2: Read/Write (Full) + +- All of Level 1 +- **MUST** support `PUT /prefixes` +- **MUST** support `PATCH /prefixes` +- **SHOULD** support `DELETE /prefixes?prefix=...` +- **SHOULD** support RDFPatch integration (PA/PD directives) + +--- + +## 13. Examples + +### Example 1: Non-Versioned Server (Fuseki-like) + +```http +GET /myDataset/prefixes +→ 200 OK + { "prefixes": { "rdf": "..." } } + +PUT /myDataset/prefixes + { "prefixes": { "foaf": "..." } } +→ 204 No Content +``` + +### Example 2: Version-Controlled Server (CHUCC-like) + +```http +GET /version/datasets/mydata/branches/main/prefixes +→ 200 OK + { "prefixes": { "rdf": "..." } } + +PUT /version/datasets/mydata/branches/main/prefixes + { "message": "Add FOAF", "prefixes": { "foaf": "..." } } +→ 201 Created + Location: /version/datasets/mydata/commits/01JCDN... + { "commitId": "01JCDN...", "message": "Add FOAF" } +``` + +--- + +## 14. References + +- [SPARQL 1.1 Query Language](https://www.w3.org/TR/sparql11-query/) +- [SPARQL 1.1 Graph Store HTTP Protocol](https://www.w3.org/TR/sparql11-http-rdf-update/) +- [RDFPatch](https://afs.github.io/rdf-patch/) +- [RFC 7231: HTTP/1.1 Semantics and Content](https://tools.ietf.org/html/rfc7231) +- [RFC 3987: Internationalized Resource Identifiers (IRIs)](https://tools.ietf.org/html/rfc3987) + +--- + +## Appendix A: Comparison with Original Proposal + +This protocol simplifies the original [non-standard draft](./Prefix_Management_Protocol_for_SPARQL_Services.md) by: + +1. **Removed graph-level support** - Dataset-level only (RDFPatch constraint) +2. **Removed capabilities endpoint** - Clients can probe with OPTIONS or try operations +3. **Removed custom media type** - Standard `application/json` sufficient +4. **Removed scope field** - Always dataset-level, no ambiguity +5. **Removed ETag concurrency** - Optional for implementations, not core protocol +6. **Simplified error format** - Implementation-specific (use RFC 7807 recommended) +7. **Added RDFPatch integration** - Natural fit for transactional servers +8. **Added version control notes** - Guidance for versioned implementations + +**Result:** 70% shorter protocol with broader applicability. + +--- + +## Appendix B: Future Extensions + +Potential future extensions (not part of v1.0): + +- **Prefix suggestions** - Analyze dataset and suggest common prefixes +- **Bulk operations** - Upload/download entire prefix catalogs +- **Prefix validation** - Check for conflicts or duplicate namespaces +- **Prefix templates** - Share prefix sets across datasets +- **Federation** - Discover prefixes from federated endpoints + +--- + +**End of Prefix Management Protocol v1.0**