Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a00c4ba
Add comprehensive SPARQL query interface
jjkoehorst Aug 15, 2025
cf00594
style sheet for sparql requirement added
jjkoehorst Aug 16, 2025
05d15a2
Merge commit 'e5d24f69045bd42fb60489ded5bee434d80d7be9' into sparql-i…
jjkoehorst Aug 16, 2025
a4cbbb4
cleaning up based on feedback
jjkoehorst Aug 29, 2025
7535fbe
Made some further changes...
jjkoehorst Aug 30, 2025
c4aff75
fixed an issue where the original implementation of DESCRIBE was igno…
jjkoehorst Aug 30, 2025
7e7ffea
some "legacy" code where it would listen to /sparql/query while thats…
jjkoehorst Aug 30, 2025
771a62f
added a filter function in case we add many more example queries (whi…
jjkoehorst Aug 30, 2025
ec6690c
a integration test to show the index page is working
stuzart Sep 4, 2025
4bfa00f
tidy how it checks the configuration and connection
stuzart Sep 4, 2025
ee62a70
an integration test performing a sparql query
stuzart Sep 4, 2025
6213e45
update the sparql query test to be easier to test, and also in future…
stuzart Sep 8, 2025
e3e0a3b
test that check you cannot insert and delete triples with a sparql qu…
stuzart Sep 8, 2025
bb3e8d6
remove test trace
stuzart Sep 15, 2025
52f25b2
a test for the html response
stuzart Sep 16, 2025
2b0b015
an initial test for an invalid sparql query. response codes and json …
stuzart Sep 16, 2025
ce61b66
Added a schema image of the demo dataset. To be updated in the future…
jjkoehorst Sep 18, 2025
c3c1453
simplified example view to work on the sparql_queries.yml file.
jjkoehorst Sep 18, 2025
70e5e12
Example query section is now collapsed by default to reduce space
jjkoehorst Sep 18, 2025
75c6885
add a before_action to block access to sparql controller if not confi…
stuzart Sep 18, 2025
dc74aab
fix to always use unauthorized access to endpoint, to prevent insert,…
stuzart Sep 18, 2025
4bd076f
sorted out some of the error reporting and response codes. 422 seemed…
stuzart Sep 18, 2025
19c019f
add test for the case where there is a single result
stuzart Sep 18, 2025
0dc71c4
test for when the repo is configured, but for some reason not available
stuzart Sep 18, 2025
27e6c0f
don't default the results, and only set if a query. Also only allow X…
stuzart Sep 18, 2025
d23c35e
split the query into its own action
stuzart Sep 24, 2025
5a57d4e
fixes and refactoring for the use of the format param, including a te…
stuzart Sep 24, 2025
f371e12
use the bootstrap_helper for the panels and put the examples in its o…
stuzart Sep 24, 2025
ad8749d
resolve copilot comments
stuzart Sep 25, 2025
e5d951d
don't show the flash[:error] when there is just a query error
stuzart Sep 25, 2025
1fdbe42
default to restricting to the public graph, but added a test to show …
stuzart Sep 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added app/assets/images/sparql/schema.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,4 @@
//= require institution-ror-typeahead
//= require fair_data_station
//= require DataTables-2.3.1/datatables.min
//= require sparql
151 changes: 151 additions & 0 deletions app/assets/javascripts/sparql.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
document.addEventListener('DOMContentLoaded', function() {
// Get DOM elements
const clearBtn = document.getElementById('clear-query');
const queryTextarea = document.getElementById('sparql_query');

if (clearBtn && queryTextarea) {
clearBtn.addEventListener('click', function() {
queryTextarea.value = '';
queryTextarea.focus();
});
}


// Use example query buttons
const useQueryButtons = document.querySelectorAll('.use-query');
useQueryButtons.forEach(button => {
button.addEventListener('click', function() {
const queryText = this.dataset.query;
if (queryTextarea) {
queryTextarea.value = queryText;
queryTextarea.focus();

// Scroll to the query form
queryTextarea.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});

// Auto-resize textarea
if (queryTextarea) {
function autoResize() {
queryTextarea.style.height = 'auto';
queryTextarea.style.height = Math.max(queryTextarea.scrollHeight, 200) + 'px';
}

queryTextarea.addEventListener('input', autoResize);
autoResize(); // Initial resize
}

// Handle clicks on URI links to execute DESCRIBE queries
document.addEventListener('click', function(e) {
if (e.target.classList.contains('external-link')) {
e.preventDefault(); // Prevent default link behavior

const uri = e.target.href;
const describeQuery = `DESCRIBE <${uri}>`;

if (queryTextarea) {
queryTextarea.value = describeQuery;
queryTextarea.focus();

// Auto-resize after setting the query
queryTextarea.style.height = 'auto';
queryTextarea.style.height = Math.max(queryTextarea.scrollHeight, 200) + 'px';

// Scroll to the query form
queryTextarea.scrollIntoView({ behavior: 'smooth', block: 'start' });

// Auto-execute the DESCRIBE query
const form = queryTextarea.closest('form');
if (form) {
form.submit();
}
}
}
});

// Handle example query dropdown
const exampleQuerySelect = document.getElementById('example-queries');

if (exampleQuerySelect && queryTextarea) {
exampleQuerySelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];

if (selectedOption.value) {
const rawQuery = selectedOption.dataset.query;

// Decode HTML entities
const tempDiv = document.createElement('div');
tempDiv.innerHTML = rawQuery;
const query = tempDiv.textContent || tempDiv.innerText || '';

// Set the decoded query in the textarea
queryTextarea.value = query;
queryTextarea.focus();

// Auto-resize after setting the query
queryTextarea.style.height = 'auto';
queryTextarea.style.height = Math.max(queryTextarea.scrollHeight, 200) + 'px';

// Reset dropdown to default
this.selectedIndex = 0;

// Scroll to the query form
queryTextarea.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
}

// Example query filter functionality
const queryFilter = document.getElementById('query-filter');
const queriesContainer = document.getElementById('example-queries-container');
const noQueriesMessage = document.getElementById('no-queries-message');

if (queryFilter && queriesContainer) {
queryFilter.addEventListener('input', function() {
const filterText = this.value.toLowerCase().trim();
const exampleQueries = queriesContainer.querySelectorAll('.example-query');
let visibleCount = 0;

exampleQueries.forEach(function(query) {
const title = query.querySelector('.query-title strong');
const description = query.querySelector('.text-muted');
const queryCode = query.querySelector('.query-code');

const titleText = title ? title.textContent.toLowerCase() : '';
const descriptionText = description ? description.textContent.toLowerCase() : '';
const codeText = queryCode ? queryCode.textContent.toLowerCase() : '';

const matchesFilter = filterText === '' ||
titleText.includes(filterText) ||
descriptionText.includes(filterText) ||
codeText.includes(filterText);

if (matchesFilter) {
query.parentElement.style.display = '';
visibleCount++;
} else {
query.parentElement.style.display = 'none';
}
});

// Show/hide "no results" message
if (noQueriesMessage) {
if (visibleCount === 0 && filterText !== '') {
noQueriesMessage.style.display = 'block';
} else {
noQueriesMessage.style.display = 'none';
}
}
});

// Clear filter when escape key is pressed
queryFilter.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
this.value = '';
this.dispatchEvent(new Event('input'));
}
});
}
});
1 change: 1 addition & 0 deletions app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,5 @@
*= require linked_extended_metadata
*= require ror-widget
*= require DataTables-2.3.1/datatables.min
*= require sparql
*/
122 changes: 122 additions & 0 deletions app/assets/stylesheets/sparql.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
.sparql-interface {
.sparql-textarea {
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
font-size: 12px;
line-height: 1.4;
background-color: #f8f9fa;
border: 2px solid #dee2e6;

&:focus {
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
}

.sparql-results {
margin-top: 2rem;

.table-responsive {
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch; // Smooth scrolling on mobile
}

table {
font-size: 12px;
min-width: 100%;
white-space: nowrap; // Prevent cell content from wrapping

th {
background-color: #f8f9fa;
font-weight: 600;
min-width: 150px; // Minimum column width
}

td {
min-width: 150px; // Minimum column width
}

.external-link {
word-break: break-all;
max-width: 300px; // Limit URI column width
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
}
}

.code-block {
max-height: 500px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 4px;

pre {
background: #f8f9fa;
border: none;
border-radius: 0;
margin: 0;
padding: 1rem;
font-size: 11px;
white-space: pre-wrap;
}
}
}

.sparql-examples {
margin-top: 2rem;

.example-query-filter {
margin-bottom: 1.5rem;

.input-group {
max-width: 400px;
}
}

.example-query {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 1rem;
margin-bottom: 1rem;

.query-title {
margin-bottom: 0.5rem;

.label {
margin-left: 0.5rem;
}
}

.query-code {
background: white;
border: 1px solid #ddd;
border-radius: 3px;
padding: 0.75rem;
font-size: 11px;
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
margin: 0.5rem 0;
white-space: pre-wrap;
word-wrap: break-word;
}

.use-query {
margin-top: 0.5rem;
}
}
}

.sparql-format-select {
width: auto;
display: inline-block;
}

.form-actions {
margin-top: 25px;

.btn + .btn {
margin-left: 0.5rem;
}
}
}
101 changes: 101 additions & 0 deletions app/controllers/sparql_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
require 'sparql/client'
require 'net/http'

class SparqlController < ApplicationController
layout 'application'

before_action :rdf_repository_configured?

def index
# Main SPARQL interface page
flash.now[:error] = 'SPARQL endpoint is configured, but not currently available.' unless rdf_repository_available?

@example_queries = load_example_queries

respond_to(&:html)
end

def query
@sparql_query = params[:sparql_query] || ''

if rdf_repository_available?
begin
@results = execute_sparql_query(@sparql_query)
rescue StandardError => e
@error = e.message
Rails.logger.error("SPARQL Query Error: #{e.message}")
end
else
@error = 'SPARQL endpoint is configured, but not currently available.'
flash[:error] = @error
end

status = @error ? :unprocessable_entity : nil # can't use :success but is the default if nil
@results ||= []

respond_to do |format|
format.html do
@example_queries = load_example_queries
render :index, status: status
end
format.json { render json: { 'results': @results, 'error': @error }.compact, status: status }
format.xml { render xml: @results.to_xml, status: status }
end
end

private

def execute_sparql_query(query)
public_graph = Seek::Rdf::RdfRepository.instance.get_configuration.public_graph
endpoint = Seek::Rdf::RdfRepository.instance.get_configuration.uri
sparql_client = SPARQL::Client.new(endpoint, graph: public_graph)
results = sparql_client.query(query)
convert_sparql_results(results)
end

def convert_sparql_results(results)
return [] if results.nil?

# Handle empty collections
return [] if results.respond_to?(:empty?) && results.empty?

# Handle different result formats
if results.respond_to?(:map)
results.map do |solution|
if solution.respond_to?(:to_h)
solution.to_h.transform_values { |v| v.respond_to?(:to_s) ? v.to_s : v }
elsif solution.respond_to?(:bindings)
solution.bindings.transform_values { |v| v.respond_to?(:to_s) ? v.to_s : v }
else
solution.to_s
end
end
else
# This handles ASK queries (boolean) and any other single values
[{ 'result' => results.to_s }]
end
end

def rdf_repository_available?
Seek::Rdf::RdfRepository.instance.available?
end

def rdf_repository_configured?
unless Seek::Rdf::RdfRepository.instance&.configured?
flash[:error] = 'SPARQL endpoint is not configured.'
redirect_to main_app.root_path
end
end

def load_example_queries
queries_file = Rails.root.join('config', 'sparql_queries.yml')
if File.exist?(queries_file)
YAML.safe_load(File.read(queries_file)) || {}
else
{}
end
rescue StandardError => e
Rails.logger.error("Failed to load SPARQL queries: #{e.message}")
{}
end
end
Loading
Loading