# Phase 3 Task 7: SPARQL Execution with Oxigraph **Goal**: Connect the Visual SPARQL Query Builder to a real RDF triplestore and execute queries against heritage institution data. **Estimated Time**: 6-8 hours **Status**: ⏳ In Progress **Dependencies**: Task 6 (Query Builder) ✅ Complete --- ## Overview Task 7 transforms the Query Builder from a SPARQL *generator* into a full SPARQL *IDE* by: 1. Installing Oxigraph RDF triplestore 2. Loading sample heritage institution data 3. Executing SPARQL queries via HTTP 4. Displaying results in interactive tables 5. Exporting results to multiple formats --- ## Step-by-Step Implementation Plan ### Step 1: Install & Configure Oxigraph (1-1.5 hours) #### 1.1 Install Oxigraph Binary ```bash # macOS (Homebrew) brew install oxigraph # Or download from GitHub releases # https://github.com/oxigraph/oxigraph/releases ``` #### 1.2 Prepare Sample RDF Data ```bash # Create data directory mkdir -p frontend/data/sample-rdf # Generate sample heritage institutions (N-Triples format) # Use existing GLAM data or create minimal test dataset ``` #### 1.3 Start Oxigraph Server ```bash # Create persistent database oxigraph_server --location frontend/data/oxigraph-db --bind 127.0.0.1:7878 # Load sample data curl -X POST \ -H 'Content-Type: application/n-triples' \ --data-binary '@frontend/data/sample-rdf/heritage-institutions.nt' \ http://localhost:7878/store ``` #### 1.4 Test SPARQL Endpoint ```bash # Test query curl -X POST \ -H 'Content-Type: application/sparql-query' \ -H 'Accept: application/sparql-results+json' \ --data 'SELECT * WHERE { ?s ?p ?o } LIMIT 10' \ http://localhost:7878/query ``` **Files to Create**: - `frontend/scripts/start-oxigraph.sh` - Server startup script - `frontend/scripts/load-sample-data.sh` - Data loading script - `frontend/data/sample-rdf/heritage-institutions.nt` - Test data - `frontend/OXIGRAPH_SETUP.md` - Setup documentation --- ### Step 2: Implement HTTP Client (1 hour) #### 2.1 Create SPARQL Client Module **File**: `src/lib/sparql/client.ts` ```typescript /** * SPARQL HTTP Client for Oxigraph * * Handles query execution, response parsing, and error handling. */ export interface SparqlEndpointConfig { url: string; timeout?: number; headers?: Record; } export interface SparqlBinding { [variable: string]: { type: 'uri' | 'literal' | 'bnode'; value: string; datatype?: string; 'xml:lang'?: string; }; } export interface SparqlResults { head: { vars: string[]; }; results: { bindings: SparqlBinding[]; }; } export interface SparqlError { message: string; statusCode?: number; query?: string; } export class SparqlClient { private config: SparqlEndpointConfig; constructor(config: SparqlEndpointConfig) { this.config = { timeout: 30000, // 30 seconds default ...config, }; } /** * Execute SELECT query */ async executeSelect(query: string): Promise { // Implementation } /** * Execute ASK query */ async executeAsk(query: string): Promise { // Implementation } /** * Execute CONSTRUCT query */ async executeConstruct(query: string): Promise { // Implementation (returns RDF triples) } /** * Execute DESCRIBE query */ async executeDescribe(query: string): Promise { // Implementation (returns RDF triples) } /** * Test endpoint connectivity */ async testConnection(): Promise { // Simple ASK query to test connection } } /** * Default client instance (configured from environment) */ export const defaultSparqlClient = new SparqlClient({ url: import.meta.env.VITE_SPARQL_ENDPOINT || 'http://localhost:7878/query', }); ``` **Features**: - Fetch API for HTTP requests - Timeout handling - Query type detection (SELECT/ASK/CONSTRUCT/DESCRIBE) - Response format negotiation (JSON for SELECT, RDF for CONSTRUCT) - Error handling with meaningful messages - Retry logic for network errors **Environment Variables**: ```bash # .env.local VITE_SPARQL_ENDPOINT=http://localhost:7878/query VITE_SPARQL_TIMEOUT=30000 ``` --- ### Step 3: Build Results Table Component (2-2.5 hours) #### 3.1 Create Results Table Component **File**: `src/components/query/ResultsTable.tsx` ```typescript /** * SPARQL Query Results Table * * Interactive table displaying query results with: * - Column sorting * - Row selection * - Pagination * - URI link rendering * - Literal type display */ import React, { useState, useMemo } from 'react'; import { type SparqlResults, type SparqlBinding } from '../../lib/sparql/client'; export interface ResultsTableProps { results: SparqlResults; onExport?: (format: 'csv' | 'json' | 'jsonld') => void; maxRowsPerPage?: number; } export const ResultsTable: React.FC = ({ results, onExport, maxRowsPerPage = 100, }) => { const [currentPage, setCurrentPage] = useState(1); const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [selectedRows, setSelectedRows] = useState>(new Set()); // Column headers from SPARQL query const columns = results.head.vars; // Paginated and sorted data const paginatedData = useMemo(() => { let data = [...results.results.bindings]; // Sort if column selected if (sortColumn) { data.sort((a, b) => { const aVal = a[sortColumn]?.value || ''; const bVal = b[sortColumn]?.value || ''; return sortDirection === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); }); } // Paginate const start = (currentPage - 1) * maxRowsPerPage; const end = start + maxRowsPerPage; return data.slice(start, end); }, [results, sortColumn, sortDirection, currentPage, maxRowsPerPage]); const totalPages = Math.ceil(results.results.bindings.length / maxRowsPerPage); // Render cell based on binding type const renderCell = (binding: any) => { if (!binding) return -; switch (binding.type) { case 'uri': return ( {shortenUri(binding.value)} ); case 'literal': return ( {binding.value} ); case 'bnode': return ( {binding.value} ); } }; return (
{/* Toolbar */}
{results.results.bindings.length} results
{/* Table */} {columns.map(col => ( ))} {paginatedData.map((row, idx) => ( {columns.map(col => renderCell(row[col]))} ))}
handleSort(col)} > {col} {sortColumn === col && (sortDirection === 'asc' ? '↑' : '↓')}
toggleRow(idx)} />
{/* Pagination */}
Page {currentPage} of {totalPages}
); }; // Helper: Shorten long URIs function shortenUri(uri: string): string { const prefixes: Record = { 'http://schema.org/': 'schema:', 'http://data.europa.eu/m8g/': 'cpov:', 'http://www.w3.org/2002/07/owl#': 'owl:', // Add more prefixes }; for (const [ns, prefix] of Object.entries(prefixes)) { if (uri.startsWith(ns)) { return uri.replace(ns, prefix); } } // Fallback: show last part after / const parts = uri.split('/'); return parts[parts.length - 1] || uri; } ``` **Styling**: `src/components/query/ResultsTable.css` **Features**: - Responsive table layout - Column sorting (click header) - Pagination (100 rows per page default) - Row selection (checkboxes) - URI rendering (clickable links with shortened display) - Literal type indicators (datatype tooltip) - Empty cell handling - Export buttons (CSV, JSON, JSON-LD) --- ### Step 4: Export Functionality (1 hour) #### 4.1 Create Export Utilities **File**: `src/lib/sparql/export.ts` ```typescript /** * SPARQL Results Export Utilities * * Convert SPARQL results to various formats and trigger downloads. */ import { type SparqlResults, type SparqlBinding } from './client'; /** * Export results as CSV */ export function exportToCsv(results: SparqlResults): string { const headers = results.head.vars; const rows = results.results.bindings; // CSV header row const csvHeaders = headers.join(','); // CSV data rows const csvRows = rows.map(row => { return headers.map(col => { const binding = row[col]; if (!binding) return ''; // Escape quotes and commas let value = binding.value.replace(/"/g, '""'); if (value.includes(',') || value.includes('"')) { value = `"${value}"`; } return value; }).join(','); }); return [csvHeaders, ...csvRows].join('\n'); } /** * Export results as JSON */ export function exportToJson(results: SparqlResults): string { return JSON.stringify(results, null, 2); } /** * Export results as JSON-LD (if applicable) */ export function exportToJsonLd(results: SparqlResults): string { // Convert SPARQL results to JSON-LD format // This requires mapping bindings to @graph structure const graph = results.results.bindings.map(binding => { const node: any = {}; for (const [key, value] of Object.entries(binding)) { if (value.type === 'uri') { node[key] = { '@id': value.value }; } else { node[key] = value.value; } } return node; }); return JSON.stringify({ '@context': { 'schema': 'http://schema.org/', 'cpov': 'http://data.europa.eu/m8g/', }, '@graph': graph, }, null, 2); } /** * Trigger browser download */ export function downloadFile(content: string, filename: string, mimeType: string): void { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } /** * Export and download results */ export function exportResults( results: SparqlResults, format: 'csv' | 'json' | 'jsonld', queryName?: string ): void { const timestamp = new Date().toISOString().split('T')[0]; const baseName = queryName || 'sparql-results'; let content: string; let filename: string; let mimeType: string; switch (format) { case 'csv': content = exportToCsv(results); filename = `${baseName}-${timestamp}.csv`; mimeType = 'text/csv'; break; case 'json': content = exportToJson(results); filename = `${baseName}-${timestamp}.json`; mimeType = 'application/json'; break; case 'jsonld': content = exportToJsonLd(results); filename = `${baseName}-${timestamp}.jsonld`; mimeType = 'application/ld+json'; break; } downloadFile(content, filename, mimeType); } ``` --- ### Step 5: Integrate with Query Builder Page (1.5 hours) #### 5.1 Update QueryBuilderPage Component Add state for query execution: ```typescript // Add to QueryBuilderPage.tsx import { defaultSparqlClient, type SparqlResults } from '../lib/sparql/client'; import { ResultsTable } from '../components/query/ResultsTable'; import { exportResults } from '../lib/sparql/export'; // Add state const [isExecuting, setIsExecuting] = useState(false); const [queryResults, setQueryResults] = useState(null); const [queryError, setQueryError] = useState(null); const [executionTime, setExecutionTime] = useState(null); // Execute query handler const handleExecuteQuery = async () => { if (!validationResult?.isValid) return; setIsExecuting(true); setQueryError(null); setQueryResults(null); const startTime = performance.now(); try { const results = await defaultSparqlClient.executeSelect(sparqlQuery); const endTime = performance.now(); setQueryResults(results); setExecutionTime(endTime - startTime); } catch (error) { setQueryError(error instanceof Error ? error.message : 'Query execution failed'); } finally { setIsExecuting(false); } }; // Export handler const handleExport = (format: 'csv' | 'json' | 'jsonld') => { if (!queryResults) return; exportResults(queryResults, format, queryName || undefined); }; ``` Add Results section to JSX: ```typescript {/* Query Results */} {queryResults && (

Query Results

{queryResults.results.bindings.length} results in {executionTime?.toFixed(2)}ms
)} {/* Query Error */} {queryError && (

Execution Error

{queryError}

)} ``` Update Execute button: ```typescript ``` --- ### Step 6: GraphContext Integration (1 hour) #### 6.1 Populate Graph from Query Results **Option A**: Convert SPARQL results to graph nodes/links ```typescript // In GraphContext or new hook function sparqlResultsToGraph(results: SparqlResults): GraphData { const nodes = new Map(); const links: Link[] = []; // Analyze query to extract subject-predicate-object patterns // This requires parsing SPARQL SELECT to understand variable roles // Simplified approach: assume ?s ?p ?o pattern results.results.bindings.forEach(binding => { if (binding.s && binding.o) { // Add subject node if (!nodes.has(binding.s.value)) { nodes.set(binding.s.value, { id: binding.s.value, label: shortenUri(binding.s.value), type: 'resource', }); } // Add object node (if URI) if (binding.o.type === 'uri' && !nodes.has(binding.o.value)) { nodes.set(binding.o.value, { id: binding.o.value, label: shortenUri(binding.o.value), type: 'resource', }); } // Add link if (binding.p && binding.o.type === 'uri') { links.push({ source: binding.s.value, target: binding.o.value, label: shortenUri(binding.p.value), }); } } }); return { nodes: Array.from(nodes.values()), links, }; } ``` **Option B**: Store results separately and allow user to visualize selected results --- ### Step 7: Testing (1 hour) #### 7.1 Unit Tests for SPARQL Client **File**: `tests/unit/sparql-client.test.ts` ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SparqlClient } from '../../src/lib/sparql/client'; describe('SparqlClient', () => { let client: SparqlClient; beforeEach(() => { client = new SparqlClient({ url: 'http://localhost:7878/query' }); }); it('should execute SELECT query', async () => { // Mock fetch response global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ head: { vars: ['s', 'p', 'o'] }, results: { bindings: [] }, }), }); const results = await client.executeSelect('SELECT * WHERE { ?s ?p ?o } LIMIT 10'); expect(results.head.vars).toEqual(['s', 'p', 'o']); }); it('should handle network errors', async () => { global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); await expect( client.executeSelect('SELECT * WHERE { ?s ?p ?o }') ).rejects.toThrow('Network error'); }); it('should handle HTTP errors', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', }); await expect( client.executeSelect('SELECT * WHERE { ?s ?p ?o }') ).rejects.toThrow(); }); // Add more tests... }); ``` #### 7.2 Integration Tests **File**: `tests/integration/query-execution.test.tsx` Test full flow: 1. Load query template 2. Validate query 3. Execute query (mocked endpoint) 4. Display results 5. Export results --- ### Step 8: Sample Data Generation (30 min) #### 8.1 Create Sample Heritage Institution Data **File**: `frontend/data/sample-rdf/heritage-institutions.nt` ```turtle # Sample N-Triples data for testing # Amsterdam Museum . "Amsterdam Museum" . . # Address . "Amsterdam" . "NL" . # Rijksmuseum . "Rijksmuseum" . . # ... more institutions ``` Or generate programmatically from existing GLAM data: ```bash # Convert LinkML instances to N-Triples python scripts/export_linkml_to_ntriples.py \ --input data/instances/ \ --output frontend/data/sample-rdf/heritage-institutions.nt ``` --- ## Files Summary ### New Files to Create ``` frontend/ ├── scripts/ │ ├── start-oxigraph.sh (Bash script) │ └── load-sample-data.sh (Bash script) ├── data/ │ ├── sample-rdf/ │ │ └── heritage-institutions.nt (Sample RDF data) │ └── oxigraph-db/ (Database directory, gitignored) ├── src/ │ ├── lib/ │ │ └── sparql/ │ │ ├── client.ts (~200 lines) │ │ └── export.ts (~150 lines) │ └── components/ │ └── query/ │ ├── ResultsTable.tsx (~300 lines) │ └── ResultsTable.css (~150 lines) └── tests/ ├── unit/ │ ├── sparql-client.test.ts (~150 lines) │ └── sparql-export.test.ts (~100 lines) └── integration/ └── query-execution.test.tsx (~200 lines) ``` **Total New Code**: ~1,250 lines (production + tests) --- ## Dependencies ### New Dependencies ```bash # None required! Using native Fetch API # Oxigraph is standalone binary, not a Node.js package ``` ### Environment Variables ```bash # .env.local VITE_SPARQL_ENDPOINT=http://localhost:7878/query VITE_SPARQL_TIMEOUT=30000 ``` --- ## Testing Strategy ### Manual Testing Checklist 1. **Oxigraph Setup** - [ ] Oxigraph binary installed - [ ] Server starts successfully - [ ] Sample data loads - [ ] Test query executes via curl 2. **HTTP Client** - [ ] SELECT query returns results - [ ] ASK query returns boolean - [ ] Network errors handled - [ ] Timeout works 3. **Results Table** - [ ] Table displays results - [ ] Column sorting works - [ ] Pagination works - [ ] URIs are clickable - [ ] Row selection works 4. **Export Functionality** - [ ] CSV export downloads - [ ] JSON export downloads - [ ] JSON-LD export downloads - [ ] Filenames include timestamp 5. **Integration** - [ ] Execute button enables when valid - [ ] Execution shows loading state - [ ] Results appear after execution - [ ] Errors display clearly - [ ] Execution time shown ### Automated Testing - [ ] 15+ unit tests for SPARQL client - [ ] 10+ unit tests for export utilities - [ ] 5+ integration tests for full flow - [ ] All existing tests still pass (148+) --- ## Timeline | Step | Task | Est. Time | Priority | |------|------|-----------|----------| | 1 | Install Oxigraph + load data | 1-1.5h | 🔥 Critical | | 2 | Implement HTTP client | 1h | 🔥 Critical | | 3 | Build results table | 2-2.5h | 🔥 Critical | | 4 | Export functionality | 1h | 🔴 High | | 5 | Integrate with page | 1.5h | 🔥 Critical | | 6 | GraphContext integration | 1h | 🟡 Medium | | 7 | Testing | 1h | 🔴 High | | 8 | Sample data | 0.5h | 🔴 High | **Total**: 8.5-10 hours (accounting for debugging) --- ## Success Criteria ✅ **Task 7 Complete When**: - [ ] Oxigraph server running locally - [ ] Sample heritage institution data loaded - [ ] SPARQL queries execute successfully - [ ] Results display in interactive table - [ ] Export to CSV/JSON/JSON-LD works - [ ] 25+ new tests passing - [ ] Documentation complete - [ ] Phase 3 = 100% complete --- ## Next Session Starting Point **Begin with**: Step 1 - Install Oxigraph and load sample data **First Commands**: ```bash # Check Oxigraph installation brew install oxigraph # Start server oxigraph_server --location frontend/data/oxigraph-db --bind 127.0.0.1:7878 # Test connectivity curl http://localhost:7878/query ``` --- **Created**: November 22, 2025 **Status**: ⏳ Ready to begin **Blocked By**: None (Task 6 complete ✅) **Estimated Completion**: 8-10 hours from start